mirror of
https://github.com/ergochat/ergo.git
synced 2026-04-09 08:57:57 +02:00
Compare commits
401 Commits
v2.11.0-rc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44cd51ef36 | ||
|
|
4fabfb8ee3 | ||
|
|
ef28345fd5 | ||
|
|
f9a11db153 | ||
|
|
73276d8685 | ||
|
|
56f0691bef | ||
|
|
9ea307c92e | ||
|
|
e6b55903f0 | ||
|
|
45548138c3 | ||
|
|
937bad6717 | ||
|
|
9e61528711 | ||
|
|
2a114dc78a | ||
|
|
da037c0162 | ||
|
|
0e76470249 | ||
|
|
93495e2cf5 | ||
|
|
620e7d13d5 | ||
|
|
218cac3037 | ||
|
|
3bc010a6d9 | ||
|
|
e4e9e20986 | ||
|
|
52fc77433e | ||
|
|
05c37122fc | ||
|
|
ca4c3c09df | ||
|
|
e7558f292c | ||
|
|
768c01c17b | ||
|
|
1b673f049f | ||
|
|
4e65b76c67 | ||
|
|
c61859927f | ||
|
|
ec7db5a02b | ||
|
|
3c41a81921 | ||
|
|
69efae2c2e | ||
|
|
5fd8ed8ed6 | ||
|
|
ee0071b37a | ||
|
|
2e6fd75525 | ||
|
|
76e8e61705 | ||
|
|
b120806fd5 | ||
|
|
23e1ef384c | ||
|
|
3e83f52f3a | ||
|
|
1e00095055 | ||
|
|
11a38b7119 | ||
|
|
c79b65cf8a | ||
|
|
8fe491156c | ||
|
|
5fa9757b43 | ||
|
|
2b1dad982f | ||
|
|
3ff0341944 | ||
|
|
2152000312 | ||
|
|
2da19a0760 | ||
|
|
5c0af196da | ||
|
|
63a04a5ff0 | ||
|
|
f28b10986e | ||
|
|
d2c9c80cc1 | ||
|
|
6386b9ef70 | ||
|
|
6ba60c89c4 | ||
|
|
0b7be24f80 | ||
|
|
9f54ea07b7 | ||
|
|
3179fdffb0 | ||
|
|
13653b5202 | ||
|
|
b26571e891 | ||
|
|
b7ede0730f | ||
|
|
05b41e18af | ||
|
|
748700877e | ||
|
|
462e568f00 | ||
|
|
3c4c5dde4d | ||
|
|
3e9fa38f6b | ||
|
|
16186d677d | ||
|
|
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 | ||
|
|
d97e964b35 | ||
|
|
010875ec9a | ||
|
|
7b525f8899 | ||
|
|
3839f8ae60 | ||
|
|
4e574b99f3 | ||
|
|
9d388d8cdb | ||
|
|
24cf5fac45 | ||
|
|
d238eaac67 | ||
|
|
0f059ea2cc | ||
|
|
dfe2a21b17 | ||
|
|
1d8bbde95c | ||
|
|
580fc7096d | ||
|
|
15c074078a | ||
|
|
4aa1aa371d | ||
|
|
a4d160b76d | ||
|
|
430387dec6 | ||
|
|
ce162e9279 | ||
|
|
97d6f9eddb | ||
|
|
6be1ec3ad6 | ||
|
|
16ab0a67b5 | ||
|
|
cc1c491afe | ||
|
|
8d80cb52e6 | ||
|
|
e11bda643e | ||
|
|
b1a0e7cc5c | ||
|
|
2d44ab1cbf | ||
|
|
3102babec8 | ||
|
|
a5af245102 | ||
|
|
4fabeed895 | ||
|
|
5671ee2a36 | ||
|
|
4d9e80fe5b | ||
|
|
7b3778989e | ||
|
|
e3bcb9b8a0 | ||
|
|
70dfe9f594 | ||
|
|
70a98ac2f1 | ||
|
|
046ef8ce94 | ||
|
|
baf5a8465d | ||
|
|
b33e1051f7 | ||
|
|
ddb804b622 | ||
|
|
3ec7f0e5cc | ||
|
|
48d139a532 | ||
|
|
556bcba465 | ||
|
|
20bfb285f0 | ||
|
|
29b4be83bc | ||
|
|
399b0b3f39 | ||
|
|
e7597876d9 | ||
|
|
3bd3c6a88a | ||
|
|
2013beb7c8 | ||
|
|
6b386ce2ac | ||
|
|
ee22bda09c | ||
|
|
202de687df | ||
|
|
4b00c6c48e | ||
|
|
8ac488a1ff | ||
|
|
f07707dfbc | ||
|
|
3b3e8c0004 | ||
|
|
f77d430d25 | ||
|
|
28d9a7ff63 | ||
|
|
b3abd0bf1d | ||
|
|
cc873efd0f | ||
|
|
3f74612e2b | ||
|
|
24ba72cfd6 | ||
|
|
17b21c8521 | ||
|
|
75bd63d0bc | ||
|
|
3c4f83cf6e | ||
| 67d10bc63b | |||
|
|
6d642bfe93 | ||
|
|
ad3ad97047 | ||
|
|
d14ff9b3d5 | ||
|
|
dfe84bc1c2 | ||
|
|
0f39fde647 | ||
|
|
7d6e48ed2a | ||
|
|
e4c8f041f2 | ||
|
|
783b579003 | ||
|
|
07cc4f8354 | ||
|
|
f100c1d0fa | ||
|
|
2aded271c5 | ||
|
|
3d4d8228aa | ||
|
|
60af8ee491 | ||
|
|
38a6d17ee5 | ||
|
|
d082ec7ab9 | ||
|
|
3e68694760 | ||
|
|
48f8c341d7 | ||
|
|
00cfe98461 | ||
|
|
bf33fba33a | ||
|
|
0710c7e12a | ||
|
|
e84793d7ee | ||
|
|
2c0928f94d | ||
|
|
0d8dcbecf6 | ||
|
|
eeec481b8d | ||
|
|
378d88fee2 | ||
|
|
c4db4984a6 | ||
|
|
04f8791dd6 | ||
|
|
37eb5f5804 | ||
|
|
6e011cd536 | ||
|
|
295a567eda | ||
|
|
db0910d82d | ||
|
|
374cf8ef97 | ||
|
|
eb83df420b | ||
|
|
3fca52ba38 | ||
|
|
3d1412a898 | ||
|
|
b155e5315b | ||
|
|
7c53b9430a | ||
|
|
3c59ce964d | ||
|
|
ae04fb3d0a | ||
|
|
ba40d57afd | ||
|
|
697f34995b | ||
|
|
19dbf3a531 | ||
|
|
8b6b2cabc3 | ||
|
|
1da11ae8ae | ||
|
|
12f7796933 | ||
|
|
fc89d72045 | ||
| 0653f90b4f | |||
|
|
abb38ce8a1 | ||
|
|
5ecf19d01e | ||
|
|
abc71684f3 | ||
|
|
9439e9b9e1 | ||
|
|
5eaf7b37e5 | ||
|
|
4317016a09 | ||
|
|
7193fa3a3c | ||
|
|
cd36604efe | ||
|
|
8199edee6c | ||
|
|
81832a26bc | ||
|
|
8690a7648b | ||
|
|
7e6c658cad | ||
|
|
eb84103865 | ||
|
|
7a82554f9d | ||
|
|
05e5fe3444 | ||
|
|
3f5de80afd | ||
|
|
b2087977d0 | ||
|
|
177133a96f | ||
|
|
b16350e559 | ||
|
|
16e214e4fb | ||
|
|
46d32520c7 | ||
|
|
f72a6fa011 | ||
|
|
1e6dee15b2 | ||
|
|
3ceff6a8b1 | ||
|
|
7ce0636276 | ||
|
|
bceae9b739 | ||
|
|
30fbfe4cc0 | ||
|
|
2a828bb783 | ||
|
|
4b3a6cb611 | ||
|
|
f00fd452be | ||
|
|
f6f7315458 | ||
|
|
1e1acdae21 | ||
|
|
df8eef5b0a | ||
|
|
23ba58b327 | ||
|
|
bf4f3008d4 | ||
|
|
63c08ce537 | ||
|
|
f7ab0fb59e |
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
@ -12,16 +12,20 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: "ubuntu-20.04"
|
runs-on: "ubuntu-24.04"
|
||||||
steps:
|
steps:
|
||||||
- name: "checkout repository"
|
- name: "checkout repository"
|
||||||
uses: "actions/checkout@v2"
|
uses: "actions/checkout@v6"
|
||||||
- name: "setup go"
|
- name: "setup go"
|
||||||
uses: "actions/setup-go@v2"
|
uses: "actions/setup-go@v6"
|
||||||
with:
|
with:
|
||||||
go-version: "1.19"
|
go-version: "1.26"
|
||||||
- name: "install python3-pytest"
|
- name: "install python3-pytest"
|
||||||
run: "sudo apt install -y python3-pytest"
|
run: "sudo apt install -y python3-pytest python3-websockets"
|
||||||
|
- name: "make minimal"
|
||||||
|
run: "make minimal"
|
||||||
|
- name: "make build"
|
||||||
|
run: "make build"
|
||||||
- name: "make install"
|
- name: "make install"
|
||||||
run: "make install"
|
run: "make install"
|
||||||
- name: "make test"
|
- name: "make test"
|
||||||
|
|||||||
10
.github/workflows/docker-image.yml
vendored
10
.github/workflows/docker-image.yml
vendored
@ -18,10 +18,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Git repository
|
- name: Checkout Git repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Authenticate to container registry
|
- name: Authenticate to container registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
@ -30,16 +30,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v3
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
- name: Setup Docker buildx driver
|
- name: Setup Docker buildx driver
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
- name: Build and publish image
|
- name: Build and publish image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
# .goreleaser.yml
|
# .goreleaser.yml
|
||||||
# Build customization
|
# Build customization
|
||||||
|
version: 2
|
||||||
project_name: ergo
|
project_name: ergo
|
||||||
builds:
|
builds:
|
||||||
- main: ergo.go
|
- main: ergo.go
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
binary: ergo
|
binary: ergo
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
@ -15,6 +18,7 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm
|
- arm
|
||||||
- arm64
|
- arm64
|
||||||
|
- riscv64
|
||||||
goarm:
|
goarm:
|
||||||
- 6
|
- 6
|
||||||
ignore:
|
ignore:
|
||||||
@ -22,30 +26,42 @@ builds:
|
|||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
- goos: windows
|
||||||
|
goarch: riscv64
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: darwin
|
||||||
|
goarch: riscv64
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: riscv64
|
||||||
- goos: openbsd
|
- goos: openbsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: openbsd
|
- goos: openbsd
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
- goos: openbsd
|
||||||
|
goarch: riscv64
|
||||||
- goos: plan9
|
- goos: plan9
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: plan9
|
- goos: plan9
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
- goos: plan9
|
||||||
|
goarch: riscv64
|
||||||
flags:
|
flags:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
|
- -tags={{.Env.ERGO_BUILD_TAGS}}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
-
|
-
|
||||||
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
name_template: >-
|
||||||
|
{{ .ProjectName }}-{{ .Version }}-
|
||||||
|
{{- if eq .Os "darwin" }}macos{{- else }}{{ .Os }}{{ end -}}-
|
||||||
|
{{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end -}}
|
||||||
|
{{ if .Arm }}v{{ .Arm }}{{ end -}}
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
replacements:
|
|
||||||
amd64: x86_64
|
|
||||||
darwin: macos
|
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
format: zip
|
||||||
@ -56,6 +72,8 @@ archives:
|
|||||||
- ergo.motd
|
- ergo.motd
|
||||||
- default.yaml
|
- default.yaml
|
||||||
- traditional.yaml
|
- traditional.yaml
|
||||||
|
- docs/API.md
|
||||||
|
- docs/BUILD.md
|
||||||
- docs/MANUAL.md
|
- docs/MANUAL.md
|
||||||
- docs/USERGUIDE.md
|
- docs/USERGUIDE.md
|
||||||
- languages/*.yaml
|
- languages/*.yaml
|
||||||
|
|||||||
287
CHANGELOG.md
287
CHANGELOG.md
@ -1,13 +1,291 @@
|
|||||||
# 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.11.0-rc1] - 2022-12-18
|
## [2.18.0] - 2026-03-22
|
||||||
|
|
||||||
We're pleased to be publishing the release candidate for 2.11.0 (the official release should follow in a week or so). This is another bugfix release aimed at improving client compatibility and keeping up with the IRCv3 specification process.
|
We're pleased to be publishing v2.18.0, a new stable release. This release adds support for PostgreSQL and SQLite as history backends, expands the HTTP API, and includes bug fixes and minor improvements.
|
||||||
|
|
||||||
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
|
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
|
||||||
|
|
||||||
Many thanks to dedekro, [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), hauser, [@jwheare](https://github.com/jwheare), [@kingter-sutjiadi](https://github.com/kingter-sutjiadi), knolle, [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder), and [@progval](https://github.com/progval) for contributing patches, reporting issues, and helping test.
|
Due to the additional database drivers included in the default build, the size of the Ergo executable binary has increased since v2.17.0 (for example, the Linux binary for x86-64 has increased from 16.2 MiB to 26.1 MiB). See [docs/BUILD.md](https://github.com/ergochat/ergo/blob/stable/docs/BUILD.md) if you need to build a smaller binary. Conversely, if you were already building from source, you may need to adjust your build commands in order to maintain parity; consult that file for details.
|
||||||
|
|
||||||
|
Many thanks to [@clbm87](https://github.com/clbm87), [@emersion](https://github.com/emersion), [@felix](https://github.com/felix), flurry, [@furudean](https://github.com/furudean), [@k4ct0](https://github.com/k4ct0), [@mauropcorrea](https://github.com/mauropcorrea), [@NyaaaWhatsUpDoc](https://github.com/NyaaaWhatsUpDoc), [@poVoq](https://github.com/poVoq), [@progval](https://github.com/progval), [@rys](https://github.com/rys), Stryker, and th0th for helpful discussions, contributing patches, reporting issues, and helping test.
|
||||||
|
|
||||||
|
### Config changes
|
||||||
|
* Added `server.postgresql` block to configure a PostgreSQL history backend. (#2322, #2347)
|
||||||
|
* Added `server.sqlite` block to configure a SQLite history backend. (#2352)
|
||||||
|
* Added `metadata.operator-only-modification` to restrict metadata changes to IRC operators with the `metadata` capability (#2287, #2369, thanks [@clbm87](https://github.com/clbm87)!)
|
||||||
|
* Added `server.initial-notice` to send a configurable notice to clients immediately after they connect, which can be used for open proxy detection (e.g., with HOPM) (#2317, thanks Stryker!)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added support for PostgreSQL and SQLite as history backends (#2322, #2352, thanks [@felix](https://github.com/felix), [@NyaaaWhatsUpDoc](https://github.com/NyaaaWhatsUpDoc), [@poVoq](https://github.com/poVoq)!)
|
||||||
|
* Added new HTTP API endpoints (thanks [@clbm87](https://github.com/clbm87), flurry, [@furudean](https://github.com/furudean), [@mauropcorrea](https://github.com/mauropcorrea)!):
|
||||||
|
* `/v1/list` to list channels (#2358)
|
||||||
|
* `/v1/defcon` to view or modify the DEFCON level (#2359)
|
||||||
|
* `/v1/ns/passwd` to change account passwords (#2329)
|
||||||
|
* Added support for `draft/ACCOUNTREQUIRED` in `005` ISUPPORT tokens when `accounts.require-sasl` is enabled (#2341)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Push notifications now include the `msgid` tag, as required by the specification (#2350)
|
||||||
|
* Fixed some cases where environment variable config overrides did not apply as expected (e.g., individual oper entries can now be overridden without replacing the entire `opers` block) (#2275, #2362, thanks th0th!)
|
||||||
|
* Fixed error cases in `CS DEOP` and `EXTJWT` causing client disconnection (#2345, #2346, thanks [@k4ct0](https://github.com/k4ct0)!)
|
||||||
|
* Fixed some `REDACT` responses (#2319, #2320)
|
||||||
|
* Fixed some `FAIL` responses to `WEBPUSH` (#2351)
|
||||||
|
* The `+l` (user limit) channel mode now rejects non-positive values with an appropriate error (#2325, thanks [@progval](https://github.com/progval)!)
|
||||||
|
* Clients monitoring a user now receive `METADATA` notifications for that user even without the `extended-monitor` capability (#2309, #2310)
|
||||||
|
* Improved handling of PROXY protocol errors on `proxy: true` listeners (#2334)
|
||||||
|
* The `accounts.bcrypt-cost` config value is now validated at config load time (#2311, #2312, thanks [@rys](https://github.com/rys)!)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* HTTP API: reorganized NickServ-related endpoints under a `/v1/ns/` prefix (`/v1/ns/info`, `/v1/ns/list`, `/v1/ns/passwd`). The previous endpoint names (`/v1/account_details`, `/v1/account_list`) are retained as aliases for backwards compatibility. (#2329)
|
||||||
|
* The `/v1/check_auth` API endpoint now supports certificate fingerprint authentication (#2354, thanks flurry!)
|
||||||
|
* Reduced the deadline for `proxy: true` listeners to read the PROXY protocol header from 1 minute to 5 seconds (#2334)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
* Added build tags to control which optional features are built; see [docs/BUILD.md](https://github.com/ergochat/ergo/blob/stable/docs/BUILD.md) for details (#2356)
|
||||||
|
* Official release builds use Go 1.26.1 (#2330)
|
||||||
|
|
||||||
|
## [2.17.0] - 2025-12-22
|
||||||
|
|
||||||
|
We're pleased to be publishing v2.17.0, a new stable release. This release adds support for the [IRCv3 metadata specification](https://ircv3.net/specs/extensions/metadata), thanks to [@thatcher-gaming](https://github.com/thatcher-gaming), as well as bug fixes and minor updates.
|
||||||
|
|
||||||
|
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
|
||||||
|
|
||||||
|
Many thanks to [@branchgrove](https://github.com/branchgrove), [@Brutus5000](https://github.com/Brutus5000), [@progval](https://github.com/progval), [@SarahRoseLives](https://github.com/SarahRoseLives), [@thatcher-gaming](https://github.com/thatcher-gaming), [@ValwareIRC](https://github.com/ValwareIRC), and Xogium for contributing patches, reporting issues, and helping test.
|
||||||
|
|
||||||
|
### Config changes
|
||||||
|
* Added `accounts.metadata` block to configure the new metadata feature. If this block is absent, metadata is disabled. See `default.yaml` for an example. (#2273)
|
||||||
|
* Added `server.idle-timeouts` for configurable idle timeouts; when unset, the previous hardcoded defaults are used (#2292, thanks [@Brutus5000](https://github.com/Brutus5000)!)
|
||||||
|
* Added `server.oper-throttle` to configure throttling for failed `OPER` attempts; when unset, this defaults to 1 attempt every 10 seconds (#2296)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Implemented support for the [draft/metadata-2](https://ircv3.net/specs/extensions/metadata) specification, allowing clients to set and retrieve metadata on accounts and channels (#2273, #2277, #2281, #2282, #2301, thanks [@thatcher-gaming](https://github.com/thatcher-gaming)!)
|
||||||
|
* Added `/v1/status` and `/v1/account_list` HTTP API endpoints (#2261, thanks [@SarahRoseLives](https://github.com/SarahRoseLives)!)
|
||||||
|
* Enhanced `/v1/account_details` API response with additional fields (#2261, thanks [@SarahRoseLives](https://github.com/SarahRoseLives)!)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed `REGISTER` command to strip guest format when applicable, matching `NS REGISTER` behavior (#2270, #2271, thanks [@ValwareIRC](https://github.com/ValwareIRC) and [@thatcher-gaming](https://github.com/thatcher-gaming)!)
|
||||||
|
* Fixed invalid `FAIL` codes in `REGISTER` command (#2269, thanks [@ValwareIRC](https://github.com/ValwareIRC)!)
|
||||||
|
* Fixed validation of web push URLs to reject non-HTTPS URLs (#2295)
|
||||||
|
* Fixed inconsistent behavior when `history.enabled` is set but `history.chathistory-maxmessages` is not (#2303, #2304, thanks [@branchgrove](https://github.com/branchgrove)!)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* The `OPER` command now imposes a throttle on all attempts, never disconnects the client on failure, and logs non-sensitive information about failed attempts (#2296, #2298, thanks Xogium!)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
* Official release builds use Go 1.25 (#2290)
|
||||||
|
* Upgraded the Docker base image from Alpine 3.19 to 3.22 (#2306)
|
||||||
|
|
||||||
|
## [2.16.0] - 2025-05-18
|
||||||
|
We're pleased to be publishing v2.16.0, a new stable release. This release contains bug fixes and some minor updates.
|
||||||
|
|
||||||
|
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
|
||||||
|
|
||||||
|
Many thanks to [@csmith](https://github.com/csmith), [@delthas](https://github.com/delthas), donio, [@emersion](https://github.com/emersion), [@KlaasT](https://github.com/KlaasT), [@knolley](https://github.com/knolley), [@Mailaender](https://github.com/Mailaender), and [@prdes](https://github.com/prdes) for reporting issues and helping test.
|
||||||
|
|
||||||
|
### Config changes
|
||||||
|
* Added `api` block for configuring the new HTTP API. If this block is absent, the API is disabled (#2231)
|
||||||
|
* Added `server.additional-isupport` for publishing arbitrary ISUPPORT tokens (#2220, #2240)
|
||||||
|
* Added `server.command-aliases` to configure aliases for server commands (#2229, #2236)
|
||||||
|
* Added options to `roleplay` to customize the NUH's sent for `NPC` and `SCENE`. Roleplay remains deprecated and disabled by default. (#2237)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
* Mitigated HTTP DoS attacks by rejecting IRC sessions that begin with an HTTP verb, such as `POST`. If you were relying on this to create IRC sessions via an HTTP client, please open an issue. (#2239)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added an HTTP API, providing programmatic access to Ergo functionality (#2231, thanks [@KlaasT](https://github.com/KlaasT)!)
|
||||||
|
* Added SAFERATE to 005 ISUPPORT tokens (#2223, thanks [@delthas](https://github.com/delthas)!)
|
||||||
|
* Added support for ed25519-sha256 for DKIM. However, enabling this algorithm is not recommended since mainstream email providers still do not support it. (#1041, #2242)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed `CHATHISTORY TARGETS` from MySQL backend reporting incorrect timestamps when the server timezone is not UTC (#2224)
|
||||||
|
* Fixed batch name parameter in `draft/isupport` responses (#2253)
|
||||||
|
* Fixed `NS UNREGISTER` not deleting the stored push subscriptions (#2254)
|
||||||
|
* Fixed cases where `NS SAREGISTER` could create clients without applying the default user modes (#2252, #2254, thanks donio!)
|
||||||
|
* Improved validation of `CHATHISTORY` parameters (#2248, #2249, thanks [@prdes](https://github.com/prdes)!)
|
||||||
|
* Added validation to ensure the MOTD is UTF-8 when `enforce-utf8` is enabled (the recommended default) (#2228, #2233, thanks [@KlaasT](https://github.com/KlaasT)!)
|
||||||
|
* The client's own `QUIT` line now respects the `server-time` capability (#2218, #2219)
|
||||||
|
* Fixed sending unnecessary replies to certain invalid `MODE` changes (#2213)
|
||||||
|
* Improved safety of ISUPPORT length limits (#2241)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* The `draft/message-redaction` capability is no longer advertised when `allow-individual-delete` is disabled (#2215, #2216, thanks [@delthas](https://github.com/delthas)!)
|
||||||
|
* Receiving the UTF-8 BOM (byte-order mark) at the start of an IRC connection now produces an explicit error (#2244, #2247, thanks [@csmith](https://github.com/csmith), [@Mailaender](https://github.com/Mailaender)!)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
* Release builds use Go 1.24.3 (#2217)
|
||||||
|
|
||||||
|
## [2.15.0] - 2025-01-26
|
||||||
|
|
||||||
|
We're pleased to be publishing v2.15.0, a new stable release. This release adds support for mobile push notifications, via the [draft/webpush](https://github.com/ircv3/ircv3-specifications/pull/471) specification. More information on this is available in the [manual](https://github.com/ergochat/ergo/blob/ab2d842b270d9df217c779df9c7a5c594d85fdd5/docs/MANUAL.md#push-notifications) and [user guide](https://github.com/ergochat/ergo/blob/ab2d842b270d9df217c779df9c7a5c594d85fdd5/docs/USERGUIDE.md#push-notifications). This feature is still considered to be in an experimental state; `default.yaml` ships with it disabled, and its configuration may have backwards-incompatible changes in the future.
|
||||||
|
|
||||||
|
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading.
|
||||||
|
|
||||||
|
This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Ergo. Otherwise, you can update the database manually by running `ergo upgradedb` (see the manual for complete instructions).
|
||||||
|
|
||||||
|
Many thanks to [@delthas](https://github.com/delthas), [@donatj](https://github.com/donatj), donio, [@emersion](https://github.com/emersion), and [@eskimo](https://github.com/eskimo) for contributing patches and helping test.
|
||||||
|
|
||||||
|
### Config changes
|
||||||
|
* Added `webpush` block to the config file to configure push notifications. See `default.yaml` for an example. Note that at this time, `default.yaml` ships with support for push notifications disabled; operators can enable them by setting `webpush.enabled: true`. In the absence of such a block, push notifications are disabled.
|
||||||
|
* We recommend the addition of `"WEBPUSH": 1` to `fakelag.command-budgets`, to speed up mobile reattach when web push is enabled. See `default.yaml` for an example.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added support for the [draft/webpush](https://github.com/ircv3/ircv3-specifications/pull/471) specification (#2205, thanks [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo)!)
|
||||||
|
* Added support for the [draft/extended-isupport](https://github.com/ircv3/ircv3-specifications/pull/543) specification (#2184, thanks [@emersion](https://github.com/emersion)!)
|
||||||
|
* `UBAN ADD` now accepts `REQUIRE-SASL` with NUH masks, i.e. k-lines (#2198, #2199)
|
||||||
|
* Ergo now publishes the `SAFELIST` ISUPPORT parameter (#2196, thanks [@delthas](https://github.com/delthas)!)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed incorrect parameters when pushing `005` (ISUPPORT) updates to clients on rehash (#2177, #2184)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
* Official release builds use Go 1.23.5
|
||||||
|
* Added a unique identifier to identify connections in debug logs. This has no privacy implications in a standard, non-debug configuration of Ergo. (#2206, thanks donio!)
|
||||||
|
* Added support for Solaris on amd64 CPUs (#2183)
|
||||||
|
|
||||||
|
## [2.14.0] - 2024-06-30
|
||||||
|
|
||||||
|
We're pleased to be publishing v2.14.0, a new stable release. This release contains primarily bug fixes, with the addition of some new authentication mechanisms for integrating with web clients.
|
||||||
|
|
||||||
|
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
|
||||||
|
|
||||||
|
Many thanks to [@al3xandros](https://github.com/al3xandros), donio, [@eeeeeta](https://github.com/eeeeeta), [@emersion](https://github.com/emersion), [@Eriner](https://github.com/Eriner), [@eskimo](https://github.com/eskimo), [@Herringway](https://github.com/Herringway), [@jwheare](https://github.com/jwheare), [@knolley](https://github.com/knolley), [@mengzhuo](https://github.com/mengzhuo), pathof, [@poVoq](https://github.com/poVoq), [@progval](https://github.com/progval), [@RNDpacman](https://github.com/RNDpacman), and [@xnaas](https://github.com/xnaas) for contributing patches, reporting issues, and helping test.
|
||||||
|
|
||||||
|
### Config changes
|
||||||
|
* Added `accounts.oauth2` and `accounts.jwt-auth` blocks for configuring OAuth2 and JWT authentication (#2004)
|
||||||
|
* Added `protocol` and `local-address` options to `accounts.registration.email-verification`, to force emails to be sent over IPv4 (or IPv6) or to force the use of a particular source address (#2142)
|
||||||
|
* Added `limits.realnamelen`, a configurable limit on the length of realnames. If unset, no limit is enforced beyond the IRC protocol line length limits (the previous behavior). (#2123, thanks [@eskimo](https://github.com/eskimo)!)
|
||||||
|
* Added the `accept-hostname` option to the webirc config block, allowing Ergo to accept hostnames passed from reverse proxies on the `WEBIRC` line. Note that this will have no effect under the default/recommended configuration, in which cloaks are used instead (#1686, #2146, thanks [@RNDpacman](https://github.com/RNDpacman)!)
|
||||||
|
* The default/recommended value of `limits.chan-list-modes` (the size limit for ban/except/invite lists) was raised to 100 (#2081, #2165, #2167)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added support for the `OAUTHBEARER` SASL mechanism, allowing Ergo to interoperate with Gamja and an OAuth2 provider (#2004, #2122, thanks [@emersion](https://github.com/emersion)!)
|
||||||
|
* Added support for the [`IRCV3BEARER` SASL mechanism](https://github.com/ircv3/ircv3-specifications/pull/545), allowing Ergo to accept OAuth2 or JWT bearer tokens (#2158)
|
||||||
|
* Added support for the legacy `rfc1459` and `rfc1459-strict` casemappings (#2099, #2159, thanks [@xnaas](https://github.com/xnaas)!)
|
||||||
|
* The new `ergo defaultconfig` subcommand prints a copy of the default config file to standard output (#2157, #2160, thanks [@al3xandros](https://github.com/al3xandros)!)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Even with `allow-truncation: false` (the recommended default), some oversized messages were being accepted and relayed with truncation. These messages will now be rejected with `417 ERR_INPUTTOOLONG` as expected (#2170)
|
||||||
|
* NICK and QUIT from invisible members of auditorium channels are no longer recorded in history (#2133, #2137, thanks [@knolley](https://github.com/knolley) and [@poVoq](https://github.com/poVoq)!)
|
||||||
|
* If channel registration was disabled, registered channels could become inaccessible after rehash; this has been fixed (#2130, thanks [@eeeeeta](https://github.com/eeeeeta)!)
|
||||||
|
* Attempts to use unrecognized SASL mechanisms no longer count against the login throttle, improving compatibility with Pidgin (#2156, thanks donio and pathof!)
|
||||||
|
* Fixed database autoupgrade on Windows, which was previously broken due to the use of a colon in the backup filename (#2139, #2140, thanks [@Herringway](https://github.com/Herringway)!)
|
||||||
|
* Fixed handling of `NS CERT ADD <user> <fp>` when an unprivileged user invokes it on themself (#2128, #2098, thanks [@Eriner](https://github.com/Eriner)!)
|
||||||
|
* Fixed missing human-readable trailing parameters for two multiline `FAIL` messages (#2043, #2162, thanks [@jwheare](https://github.com/jwheare) and [@progval](https://github.com/progval)!)
|
||||||
|
* Fixed symbol sent by `353 RPL_NAMREPLY` for secret channels (#2144, #2145, thanks savoyard!)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Trying to claim a registered nickname that is also actually in use by another client now produces `433 ERR_NICKNAMEINUSE` as expected (#2135, #2136, thanks savoyard!)
|
||||||
|
* `SAMODE` now overrides the enforcement of `limits.chan-list-modes` (the size limit for ban/except/invite lists) (#2081, #2165)
|
||||||
|
* Certain unsuccessful `MODE` changes no longer send `324 RPL_CHANNELMODEIS` and `329 RPL_CREATIONTIME` (#2163)
|
||||||
|
* Debug logging for environment variable configuration overrides no longer prints the value, only the key (#2129, #2132, thanks [@eeeeeta](https://github.com/eeeeeta)!)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
* Official release builds use Go 1.22.4
|
||||||
|
* Added a linux/riscv64 release (#2172, #2173, thanks [@mengzhuo](https://github.com/mengzhuo)!)
|
||||||
|
|
||||||
|
## [2.13.1] - 2024-05-06
|
||||||
|
|
||||||
|
Ergo 2.13.1 is a bugfix release, fixing an exploitable deadlock that could lead to a denial of service. We regret the oversight.
|
||||||
|
|
||||||
|
This release includes no changes to the config file format or database format.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
* Fixed an exploitable deadlock that could lead to a denial of service (#2149)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
* Official release builds use Go 1.22.2
|
||||||
|
|
||||||
|
|
||||||
|
## [2.13.0] - 2024-01-14
|
||||||
|
|
||||||
|
We're pleased to be publishing v2.13.0, a new stable release. This is a bugfix release that fixes some issues, including a crash.
|
||||||
|
|
||||||
|
This release includes no changes to the config file format or database format.
|
||||||
|
|
||||||
|
Many thanks to [@dallemon](https://github.com/dallemon), [@jwheare](https://github.com/jwheare), [@Mikaela](https://github.com/Mikaela), [@nealey](https://github.com/nealey), and [@Sheikah45](https://github.com/Sheikah45) for contributing patches, reporting issues, and helping test.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Fixed a (hopefully rare) crash when persisting always-on client statuses (#2113, #2117, thanks [@Sheikah45](https://github.com/Sheikah45)!)
|
||||||
|
* Fixed not being able to message channels with `/` (or the configured `RELAYMSG` separator) in their names (#2114, thanks [@Mikaela](https://github.com/Mikaela)!)
|
||||||
|
* Verification emails now always include a `Message-ID` header, improving compatibility with Gmail (#2108, #2110)
|
||||||
|
* Improved human-readable description of `REDACT_FORBIDDEN` (#2101, thanks [@jwheare](https://github.com/jwheare)!)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
* Removed numerics associated with the retired ACC spec (#2109, #2111, thanks [@jwheare](https://github.com/jwheare)!)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
* Upgraded the Docker base image from Alpine 3.13 to 3.19. The resulting images are incompatible with Docker 19.x and lower (all currently non-EOL Docker versions should be supported). (#2103)
|
||||||
|
* Official release builds use Go 1.21.6
|
||||||
|
|
||||||
|
|
||||||
|
## [2.12.0] - 2023-10-10
|
||||||
|
|
||||||
|
We're pleased to be publishing v2.12.0, a new stable release. This is another bugfix release aimed at improving client compatibility and keeping up with the IRCv3 specification process.
|
||||||
|
|
||||||
|
This release includes changes to the config file format, one of which is a compatibility break: if you were using `accounts.email-verification.blacklist-regexes`, you can restore the previous functionality by renaming `blacklist-regexes` to `address-blacklist` and setting the additional key `address-blacklist-syntax: regex`. See [default.yaml](https://github.com/ergochat/ergo/blob/e7597876d987a6fc061b768fcf878d0035d1c85a/default.yaml#L422-L424) for an example; for more details, see the "Changed" section below.
|
||||||
|
|
||||||
|
This release includes a database change. If you have `datastore.autoupgrade` set to `true` in your configuration, it will be automatically applied when you restart Ergo. Otherwise, you can update the database manually by running `ergo upgradedb` (see the manual for complete instructions).
|
||||||
|
|
||||||
|
Many thanks to [@adsr](https://github.com/adsr), [@avollmerhaus](https://github.com/avollmerhaus), [@csmith](https://github.com/csmith), [@EchedeyLR](https://github.com/EchedeyLR), [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), [@julio-b](https://github.com/julio-b), knolle, [@KoxSosen](https://github.com/KoxSosen), [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), and [@progval](https://github.com/progval) for contributing patches, reporting issues, and helping test.
|
||||||
|
|
||||||
|
### Config changes
|
||||||
|
* Removed `accounts.email-verification.blacklist-regexes` in favor of `address-blacklist`, `address-blacklist-syntax`, and `address-blacklist-file`. See the "Changed" section below for the semantics of these new keys. (#1997, #2088)
|
||||||
|
* Added `implicit-tls` (TLS from the first byte) support for MTAs (#2048, #2049, thanks [@EchedeyLR](https://github.com/EchedeyLR)!)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed an edge case under `allow-truncation: true` (the recommended default is `false`) where Ergo could truncate a message in the middle of a UTF-8 codepoint (#2074)
|
||||||
|
* Fixed `CHATHISTORY TARGETS` being sent in a batch even without negotiation of the `batch` capability (#2066, thanks [@julio-b](https://github.com/julio-b)!)
|
||||||
|
* Errors from `/REHASH` are now properly sanitized before being sent to the user, fixing an edge case where they would be dropped (#2031, thanks [@eskimo](https://github.com/eskimo)!
|
||||||
|
* Fixed some edge cases in auto-away aggregation (#2044)
|
||||||
|
* Fixed a FAIL code sent by draft/account-registration (#2092, thanks [@progval](https://github.com/progval)!)
|
||||||
|
* Fixed a socket leak in the ident client (default/recommended configurations of Ergo disable ident and are not affected by this issue) (#2089)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Bouncer reattach from an "insecure" session is no longer disallowed. We continue to recommend that operators preemptively disable all insecure transports, such as plaintext listeners (#2013)
|
||||||
|
* Email addresses are now converted to lowercase before checking them against the blacklist (#1997, #2088)
|
||||||
|
* The default syntax for the email address blacklist is now "glob" (expressions with `*` and `?` as wildcard characters), as opposed to the full [Go regular expression syntax](https://github.com/google/re2/wiki/Syntax). To enable full regular expression syntax, set `address-blacklist-syntax: regex`.
|
||||||
|
* Due to line length limitations, some capabilities are now hidden from clients that only support version 301 CAP negotiation. To the best of our knowledge, all clients that support these capabilities also support version 302 CAP negotiation, rendering this moot (#2068)
|
||||||
|
* The default/recommended configuration now advertises the SCRAM-SHA-256 SASL method. We still do not recommend using this method in production. (#2032)
|
||||||
|
* Improved KILL messages (#2053, #2041, thanks [@mogad0n](https://github.com/mogad0n)!)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added support for automatically joining new clients to a channel or channels (#2077, #2079, thanks [@adsr](https://github.com/adsr)!)
|
||||||
|
* Added implicit TLS (TLS from the first byte) support for MTAs (#2048, #2049, thanks [@EchedeyLR](https://github.com/EchedeyLR)!)
|
||||||
|
* Added support for [draft/message-redaction](https://github.com/ircv3/ircv3-specifications/pull/524) (#2065, thanks [@progval](https://github.com/progval)!)
|
||||||
|
* Added support for [draft/pre-away](https://github.com/ircv3/ircv3-specifications/pull/514) (#2044)
|
||||||
|
* Added support for [draft/no-implicit-names](https://github.com/ircv3/ircv3-specifications/pull/527) (#2083)
|
||||||
|
* Added support for the [MSGREFTYPES](https://ircv3.net/specs/extensions/chathistory#isupport-tokens) 005 token (#2042)
|
||||||
|
* Ergo now advertises the [standard-replies](https://ircv3.net/specs/extensions/standard-replies) capability. Requesting this capability does not change Ergo's behavior.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
* Release builds are now statically linked by default. This should not affect normal chat operations, but may disrupt attempts to connect to external services (e.g. MTAs) that are configured using a hostname that relies on libc's name resolution behavior. To restore the old behavior, build from source with `CGO_ENABLED=1`. (#2023)
|
||||||
|
* Upgraded to Go 1.21 (#2045, #2084); official release builds use Go 1.21.3, which includes a fix for CVE-2023-44487
|
||||||
|
* The default `make` target is now `build` (which builds an `ergo` binary in the working directory) instead of `install` (which builds and installs an `ergo` binary to `${GOPATH}/bin/ergo`). Take note if building from source, or testing Ergo in development! (#2047)
|
||||||
|
* `make irctest` now depends on `make install`, in an attempt to ensure that irctest runs against the intended development version of Ergo (#2047)
|
||||||
|
|
||||||
|
## [2.11.1] - 2022-01-22
|
||||||
|
|
||||||
|
Ergo 2.11.1 is a bugfix release, fixing a denial-of-service issue in our websocket implementation. We regret the oversight.
|
||||||
|
|
||||||
|
This release includes no changes to the config file format or database file format.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
* Fixed a denial-of-service issue affecting websocket clients (#2039)
|
||||||
|
|
||||||
|
## [2.11.0] - 2022-12-25
|
||||||
|
|
||||||
|
We're pleased to be publishing v2.11.0, a new stable release. This is another bugfix release aimed at improving client compatibility and keeping up with the IRCv3 specification process.
|
||||||
|
|
||||||
|
This release includes changes to the config file format, all of which are fully backwards-compatible and do not require updating the file before upgrading. It includes no changes to the database file format.
|
||||||
|
|
||||||
|
Many thanks to dedekro, [@emersion](https://github.com/emersion), [@eskimo](https://github.com/eskimo), [@FiskFan1999](https://github.com/FiskFan1999), hauser, [@jwheare](https://github.com/jwheare), [@kingter-sutjiadi](https://github.com/kingter-sutjiadi), knolle, [@Mikaela](https://github.com/Mikaela), [@mogad0n](https://github.com/mogad0n), [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder), and [@progval](https://github.com/progval) for contributing patches, reporting issues, and helping test.
|
||||||
|
|
||||||
### Config changes
|
### Config changes
|
||||||
|
|
||||||
@ -19,7 +297,6 @@ Many thanks to dedekro, [@emersion](https://github.com/emersion), [@eskimo](http
|
|||||||
* Network services like `NickServ` now appear in `WHO` responses where applicable (#1850, thanks [@emersion](https://github.com/emersion)!)
|
* Network services like `NickServ` now appear in `WHO` responses where applicable (#1850, thanks [@emersion](https://github.com/emersion)!)
|
||||||
* The `extended-monitor` capability now appears under its ratified name (#2006, thanks [@progval](https://github.com/progval)!)
|
* The `extended-monitor` capability now appears under its ratified name (#2006, thanks [@progval](https://github.com/progval)!)
|
||||||
* `TAGMSG` no longer receives automatic `RPL_AWAY` responses (#1983, thanks [@eskimo](https://github.com/eskimo)!)
|
* `TAGMSG` no longer receives automatic `RPL_AWAY` responses (#1983, thanks [@eskimo](https://github.com/eskimo)!)
|
||||||
* Sending `SIGUSR1` to the Ergo process now prints a full goroutine stack dump to stderr, allowing debugging even when the HTTP pprof listener is disabled (#1975)
|
|
||||||
* `UBAN` now states explicitly that bans without a time limit have "indefinite" duration (#1988, thanks [@mogad0n](https://github.com/mogad0n)!)
|
* `UBAN` now states explicitly that bans without a time limit have "indefinite" duration (#1988, thanks [@mogad0n](https://github.com/mogad0n)!)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@ -30,10 +307,12 @@ Many thanks to dedekro, [@emersion](https://github.com/emersion), [@eskimo](http
|
|||||||
* Fixed handling of the address `::1` in WHOX output (#1980, thanks knolle!)
|
* Fixed handling of the address `::1` in WHOX output (#1980, thanks knolle!)
|
||||||
* Fixed handling of `AWAY` with an empty parameter (the de facto standard is to treat as a synonym for no parameter, which means "back") (#1996, thanks [@emersion](https://github.com/emersion), [@jwheare](https://github.com/jwheare)!)
|
* Fixed handling of `AWAY` with an empty parameter (the de facto standard is to treat as a synonym for no parameter, which means "back") (#1996, thanks [@emersion](https://github.com/emersion), [@jwheare](https://github.com/jwheare)!)
|
||||||
* Fixed incorrect handling of some invalid modes in `CS AMODE` (#2002, thanks [@eskimo](https://github.com/eskimo)!)
|
* Fixed incorrect handling of some invalid modes in `CS AMODE` (#2002, thanks [@eskimo](https://github.com/eskimo)!)
|
||||||
|
* Fixed incorrect help text for `NS SAVERIFY` (#2021, thanks [@FiskFan1999](https://github.com/FiskFan1999)!)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
* Added the `draft/persistence` capability and associated `PERSISTENCE` command. This is a first attempt to standardize Ergo's "always-on" functionality so that clients can interact with it programmatically. (#1982)
|
* Added the `draft/persistence` capability and associated `PERSISTENCE` command. This is a first attempt to standardize Ergo's "always-on" functionality so that clients can interact with it programmatically. (#1982)
|
||||||
|
* Sending `SIGUSR1` to the Ergo process now prints a full goroutine stack dump to stderr, allowing debugging even when the HTTP pprof listener is disabled (#1975)
|
||||||
|
|
||||||
### Internal
|
### Internal
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
## build ergo binary
|
## build ergo binary
|
||||||
FROM golang:1.19-alpine AS build-env
|
FROM docker.io/golang:1.26-alpine3.22 AS build-env
|
||||||
|
|
||||||
RUN apk add -U --force-refresh --no-cache --purge --clean-protected -l -u make git
|
RUN apk upgrade -U --force-refresh --no-cache && apk add --no-cache --purge --clean-protected -l -u make git
|
||||||
|
|
||||||
# copy ergo source
|
# copy ergo source
|
||||||
WORKDIR /go/src/github.com/ergochat/ergo
|
WORKDIR /go/src/github.com/ergochat/ergo
|
||||||
@ -13,10 +13,10 @@ RUN sed -i 's/^\(\s*\)\"127.0.0.1:6667\":.*$/\1":6667":/' /go/src/github.com/erg
|
|||||||
sed -i 's/^\s*\"\[::1\]:6667\":.*$//' /go/src/github.com/ergochat/ergo/default.yaml
|
sed -i 's/^\s*\"\[::1\]:6667\":.*$//' /go/src/github.com/ergochat/ergo/default.yaml
|
||||||
|
|
||||||
# compile
|
# compile
|
||||||
RUN make
|
RUN make install
|
||||||
|
|
||||||
## build ergo container
|
## build ergo container
|
||||||
FROM alpine:3.13
|
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>" \
|
||||||
|
|||||||
47
Makefile
47
Makefile
@ -1,37 +1,60 @@
|
|||||||
.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)
|
||||||
|
|
||||||
|
# disable linking against native libc / libpthread by default;
|
||||||
|
# this can be overridden by passing CGO_ENABLED=1 to make
|
||||||
|
export CGO_ENABLED ?= 0
|
||||||
|
|
||||||
|
# build tags for the maximalist build with everything included
|
||||||
|
full_tags = i18n mysql postgresql sqlite
|
||||||
|
|
||||||
|
# build everything by default; override by passing, e.g. ERGO_BUILD_TAGS="mysql postgresql"
|
||||||
|
ERGO_BUILD_TAGS ?= $(full_tags)
|
||||||
|
|
||||||
capdef_file = ./irc/caps/defs.go
|
capdef_file = ./irc/caps/defs.go
|
||||||
|
|
||||||
all: install
|
.PHONY: all
|
||||||
|
all: build
|
||||||
install:
|
|
||||||
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 -tags "$(ERGO_BUILD_TAGS)" -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install:
|
||||||
|
go install -v -tags "$(ERGO_BUILD_TAGS)" -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
|
||||||
|
|
||||||
|
.PHONY: release
|
||||||
release:
|
release:
|
||||||
goreleaser --skip-publish --rm-dist
|
ERGO_BUILD_TAGS="$(ERGO_BUILD_TAGS)" goreleaser --skip=publish --clean
|
||||||
|
|
||||||
|
.PHONY: minimal
|
||||||
|
minimal:
|
||||||
|
go build -v -tags "" -ldflags "-X main.commit=$(GIT_COMMIT) -X main.version=$(GIT_TAG)"
|
||||||
|
|
||||||
|
.PHONY: capdefs
|
||||||
capdefs:
|
capdefs:
|
||||||
python3 ./gencapdefs.py > ${capdef_file}
|
python3 ./gencapdefs.py > ${capdef_file}
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
test:
|
test:
|
||||||
python3 ./gencapdefs.py | diff - ${capdef_file}
|
python3 ./gencapdefs.py | diff - ${capdef_file}
|
||||||
go test ./...
|
go test -tags "$(full_tags)" ./...
|
||||||
go vet ./...
|
go vet -tags "$(full_tags)" ./...
|
||||||
|
go test -tags "" ./...
|
||||||
|
go vet -tags "" ./...
|
||||||
./.check-gofmt.sh
|
./.check-gofmt.sh
|
||||||
|
|
||||||
smoke:
|
.PHONY: smoke
|
||||||
|
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
|
||||||
|
|
||||||
irctest:
|
.PHONY: irctest
|
||||||
|
irctest: install
|
||||||
git submodule update --init
|
git submodule update --init
|
||||||
cd irctest && make ergo
|
cd irctest && make ergo
|
||||||
|
|||||||
6
README
6
README
@ -33,15 +33,15 @@ Modify the config file as needed (the recommendations at the top may be helpful)
|
|||||||
|
|
||||||
To generate passwords for opers and connect passwords, you can use this command:
|
To generate passwords for opers and connect passwords, you can use this command:
|
||||||
|
|
||||||
$ ergo genpasswd
|
$ ./ergo genpasswd
|
||||||
|
|
||||||
If you need to generate self-signed TLS certificates, use this command:
|
If you need to generate self-signed TLS certificates, use this command:
|
||||||
|
|
||||||
$ ergo mkcerts
|
$ ./ergo mkcerts
|
||||||
|
|
||||||
You are now ready to start Ergo!
|
You are now ready to start Ergo!
|
||||||
|
|
||||||
$ ergo run
|
$ ./ergo run
|
||||||
|
|
||||||
For further instructions, consult the manual. A copy of the manual should be
|
For further instructions, consult the manual. A copy of the manual should be
|
||||||
included in your release under `docs/MANUAL.md`. Or you can view it on the
|
included in your release under `docs/MANUAL.md`. Or you can view it on the
|
||||||
|
|||||||
20
README.md
20
README.md
@ -54,9 +54,9 @@ Extract it into a folder, then run the following commands:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp default.yaml ircd.yaml
|
cp default.yaml ircd.yaml
|
||||||
vim ircd.yaml # modify the config file to your liking
|
vim ircd.yaml # modify the config file to your liking
|
||||||
ergo mkcerts
|
./ergo mkcerts
|
||||||
ergo run # server should be ready to go!
|
./ergo run # server should be ready to go!
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** See the [productionizing guide in our manual](https://github.com/ergochat/ergo/blob/stable/docs/MANUAL.md#productionizing-with-systemd) for recommendations on how to run a production network, including obtaining valid TLS certificates.
|
**Note:** See the [productionizing guide in our manual](https://github.com/ergochat/ergo/blob/stable/docs/MANUAL.md#productionizing-with-systemd) for recommendations on how to run a production network, including obtaining valid TLS certificates.
|
||||||
@ -76,16 +76,18 @@ to the GitHub Container Registry at [ghcr.io/ergochat/ergo](https://ghcr.io/ergo
|
|||||||
|
|
||||||
### From Source
|
### From Source
|
||||||
|
|
||||||
You can also clone this repository and build from source. Typical deployments should use the `stable` branch, which points to the latest stable release. In general, `stable` should coincide with the latest published tag that is not designated as a beta or release candidate (for example, `v2.7.0-rc1` was an unstable release candidate and `v2.7.0` was the corresponding stable release), so you can also identify the latest stable release tag on the [releases page](https://github.com/ergochat/ergo/releases) and build that.
|
You can also clone this repository and build from source. A quick start guide:
|
||||||
|
|
||||||
The `master` branch is not recommended for production use since it may contain bugs, and because the forwards compatibility guarantees for the config file and the database that apply to releases do not apply to master. That is to say, running master may result in changes to your database that end up being incompatible with future versions of Ergo.
|
1. Obtain an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Check the output of `go version` to ensure it was installed correctly.
|
||||||
|
1. Clone the repository.
|
||||||
|
1. `git checkout stable`
|
||||||
|
1. `make`
|
||||||
|
1. You should now have a binary named `ergo` in the working directory.
|
||||||
|
|
||||||
|
Ergo vendors all its dependencies, so you will not need to fetch any dependencies remotely. For more information, including on build customization, see [docs/BUILD.md](https://github.com/ergochat/ergo/blob/stable/docs/BUILD.md).
|
||||||
|
|
||||||
For information on contributing to Ergo, see [DEVELOPING.md](https://github.com/ergochat/ergo/blob/master/DEVELOPING.md).
|
For information on contributing to Ergo, see [DEVELOPING.md](https://github.com/ergochat/ergo/blob/master/DEVELOPING.md).
|
||||||
|
|
||||||
#### Building
|
|
||||||
|
|
||||||
You'll need an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Once that's installed (check the output of `go version`), just check out your desired branch or tag and run `make build`. This will produce an executable binary named `ergo` in the base directory of the project. (Ergo vendors all its dependencies, so you will not need to fetch any dependencies remotely.)
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The default config file [`default.yaml`](default.yaml) helps walk you through what each option means and changes.
|
The default config file [`default.yaml`](default.yaml) helps walk you through what each option means and changes.
|
||||||
|
|||||||
226
default.yaml
226
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"
|
||||||
@ -164,7 +166,10 @@ server:
|
|||||||
# the value must begin with a '~' character. comment out / omit to disable:
|
# the value must begin with a '~' character. comment out / omit to disable:
|
||||||
coerce-ident: '~u'
|
coerce-ident: '~u'
|
||||||
|
|
||||||
# password to login to the server, generated using `ergo genpasswd`:
|
# 'password' allows you to require a global, shared password (the IRC `PASS` command)
|
||||||
|
# to connect to the server. for operator passwords, see the `opers` section of the
|
||||||
|
# config. for a more secure way to create a private server, see the `require-sasl`
|
||||||
|
# section. you must hash the password with `ergo genpasswd`, then enter the hash here:
|
||||||
#password: "$2a$04$0123456789abcdef0123456789abcdef0123456789abcdef01234"
|
#password: "$2a$04$0123456789abcdef0123456789abcdef0123456789abcdef01234"
|
||||||
|
|
||||||
# motd filename
|
# motd filename
|
||||||
@ -175,6 +180,21 @@ server:
|
|||||||
# if this is true, the motd is escaped using formatting codes like $c, $b, and $i
|
# if this is true, the motd is escaped using formatting codes like $c, $b, and $i
|
||||||
motd-formatting: true
|
motd-formatting: true
|
||||||
|
|
||||||
|
# send a configurable notice to clients immediately after they connect,
|
||||||
|
# as a way of detecting open proxies
|
||||||
|
#initial-notice: "*** Welcome to the Ergo IRC server"
|
||||||
|
|
||||||
|
# idle timeouts for inactive clients
|
||||||
|
idle-timeouts:
|
||||||
|
# give the client this long to complete connection registration (i.e. the initial
|
||||||
|
# IRC handshake, including capability negotiation and SASL)
|
||||||
|
registration: 60s
|
||||||
|
# if the client hasn't sent anything for this long, send them a PING
|
||||||
|
ping: 1m30s
|
||||||
|
# if the client hasn't sent anything for this long (including the PONG to the
|
||||||
|
# above PING), disconnect them
|
||||||
|
disconnect: 2m30s
|
||||||
|
|
||||||
# relaying using the RELAYMSG command
|
# relaying using the RELAYMSG command
|
||||||
relaymsg:
|
relaymsg:
|
||||||
# is relaymsg enabled at all?
|
# is relaymsg enabled at all?
|
||||||
@ -215,6 +235,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
|
||||||
@ -349,6 +373,10 @@ server:
|
|||||||
secure-nets:
|
secure-nets:
|
||||||
# - "10.0.0.0/8"
|
# - "10.0.0.0/8"
|
||||||
|
|
||||||
|
# allow attempts to OPER with a password at most this often. defaults to
|
||||||
|
# 10 seconds when unset.
|
||||||
|
oper-throttle: 10s
|
||||||
|
|
||||||
# Ergo will write files to disk under certain circumstances, e.g.,
|
# Ergo will write files to disk under certain circumstances, e.g.,
|
||||||
# CPU profiling or data export. by default, these files will be written
|
# CPU profiling or data export. by default, these files will be written
|
||||||
# to the working directory. set this to customize:
|
# to the working directory. set this to customize:
|
||||||
@ -361,12 +389,23 @@ server:
|
|||||||
# in a "closed-loop" system where you control the server and all the clients,
|
# in a "closed-loop" system where you control the server and all the clients,
|
||||||
# you may want to increase the maximum (non-tag) length of an IRC line from
|
# you may want to increase the maximum (non-tag) length of an IRC line from
|
||||||
# the default value of 512. DO NOT change this on a public server:
|
# the default value of 512. DO NOT change this on a public server:
|
||||||
# max-line-len: 512
|
#max-line-len: 512
|
||||||
|
|
||||||
# send all 0's as the LUSERS (user counts) output to non-operators; potentially useful
|
# send all 0's as the LUSERS (user counts) output to non-operators; potentially useful
|
||||||
# if you don't want to publicize how popular the server is
|
# if you don't want to publicize how popular the server is
|
||||||
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?
|
||||||
@ -402,6 +441,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:
|
||||||
@ -414,8 +457,15 @@ accounts:
|
|||||||
# port: 25
|
# port: 25
|
||||||
# username: "admin"
|
# username: "admin"
|
||||||
# password: "hunter2"
|
# password: "hunter2"
|
||||||
blacklist-regexes:
|
# implicit-tls: false # TLS from the first byte, typically on port 465
|
||||||
# - ".*@mailinator.com"
|
# addresses that are not accepted for registration:
|
||||||
|
address-blacklist:
|
||||||
|
# - "*@mailinator.com"
|
||||||
|
address-blacklist-syntax: "glob" # change to "regex" for regular expressions
|
||||||
|
# file of newline-delimited address blacklist entries (no enclosing quotes)
|
||||||
|
# in the above syntax (i.e. either globs or regexes). supersedes
|
||||||
|
# address-blacklist if set:
|
||||||
|
# address-blacklist-file: "/path/to/address-blacklist-file"
|
||||||
timeout: 60s
|
timeout: 60s
|
||||||
# email-based password reset:
|
# email-based password reset:
|
||||||
password-reset:
|
password-reset:
|
||||||
@ -447,6 +497,10 @@ accounts:
|
|||||||
# this is useful for compatibility with old clients that don't support SASL
|
# this is useful for compatibility with old clients that don't support SASL
|
||||||
login-via-pass-command: true
|
login-via-pass-command: true
|
||||||
|
|
||||||
|
# advertise the SCRAM-SHA-256 authentication method. set to false in case of
|
||||||
|
# compatibility issues with certain clients:
|
||||||
|
advertise-scram: true
|
||||||
|
|
||||||
# require-sasl controls whether clients are required to have accounts
|
# require-sasl controls whether clients are required to have accounts
|
||||||
# (and sign into them using SASL) to connect to the server
|
# (and sign into them using SASL) to connect to the server
|
||||||
require-sasl:
|
require-sasl:
|
||||||
@ -487,7 +541,7 @@ accounts:
|
|||||||
# 1. these nicknames cannot be registered or reserved
|
# 1. these nicknames cannot be registered or reserved
|
||||||
# 2. if a client is automatically renamed by the server,
|
# 2. if a client is automatically renamed by the server,
|
||||||
# this is the template that will be used (e.g., Guest-nccj6rgmt97cg)
|
# this is the template that will be used (e.g., Guest-nccj6rgmt97cg)
|
||||||
# 3. if enforce-guest-format (see below) is enabled, clients without
|
# 3. if force-guest-format (see below) is enabled, clients without
|
||||||
# a registered account will have this template applied to their
|
# a registered account will have this template applied to their
|
||||||
# nicknames (e.g., 'katie' will become 'Guest-katie')
|
# nicknames (e.g., 'katie' will become 'Guest-katie')
|
||||||
guest-nickname-format: "Guest-*"
|
guest-nickname-format: "Guest-*"
|
||||||
@ -572,6 +626,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
|
||||||
@ -607,6 +695,12 @@ channels:
|
|||||||
# (0 or omit for no expiration):
|
# (0 or omit for no expiration):
|
||||||
invite-expiration: 24h
|
invite-expiration: 24h
|
||||||
|
|
||||||
|
# channels that new clients will automatically join. this should be used with
|
||||||
|
# caution, since traditional IRC users will likely view it as an antifeature.
|
||||||
|
# it may be useful in small community networks that have a single "primary" channel:
|
||||||
|
#auto-join:
|
||||||
|
# - "#lounge"
|
||||||
|
|
||||||
# operator classes:
|
# operator classes:
|
||||||
# an operator has a single "class" (defining a privilege level), which can include
|
# an operator has a single "class" (defining a privilege level), which can include
|
||||||
# multiple "capabilities" (defining privileged actions they can take). all
|
# multiple "capabilities" (defining privileged actions they can take). all
|
||||||
@ -649,6 +743,7 @@ oper-classes:
|
|||||||
- "history" # modify or delete history messages
|
- "history" # modify or delete history messages
|
||||||
- "defcon" # use the DEFCON command (restrict server capabilities)
|
- "defcon" # use the DEFCON command (restrict server capabilities)
|
||||||
- "massmessage" # message all users on the server
|
- "massmessage" # message all users on the server
|
||||||
|
- "metadata" # modify arbitrary metadata on channels and users
|
||||||
|
|
||||||
# ircd operators
|
# ircd operators
|
||||||
opers:
|
opers:
|
||||||
@ -713,7 +808,7 @@ logging:
|
|||||||
# be logged, even if you explicitly include it
|
# be logged, even if you explicitly include it
|
||||||
#
|
#
|
||||||
# useful types include:
|
# useful types include:
|
||||||
# * everything (usually used with exclusing some types below)
|
# * everything (usually used with excluding some types below)
|
||||||
# server server startup, rehash, and shutdown events
|
# server server startup, rehash, and shutdown events
|
||||||
# accounts account registration and authentication
|
# accounts account registration and authentication
|
||||||
# channels channel creation and operations
|
# channels channel creation and operations
|
||||||
@ -757,7 +852,7 @@ lock-file: "ircd.lock"
|
|||||||
|
|
||||||
# datastore configuration
|
# datastore configuration
|
||||||
datastore:
|
datastore:
|
||||||
# path to the datastore
|
# path to the database file (used to store account and channel registrations):
|
||||||
path: ircd.db
|
path: ircd.db
|
||||||
|
|
||||||
# if the database schema requires an upgrade, `autoupgrade` will attempt to
|
# if the database schema requires an upgrade, `autoupgrade` will attempt to
|
||||||
@ -780,6 +875,43 @@ datastore:
|
|||||||
# this may be necessary to prevent middleware from closing your connections:
|
# this may be necessary to prevent middleware from closing your connections:
|
||||||
#conn-max-lifetime: 180s
|
#conn-max-lifetime: 180s
|
||||||
|
|
||||||
|
# connection information for PostgreSQL (currently only used for persistent history)
|
||||||
|
postgresql:
|
||||||
|
enabled: false
|
||||||
|
host: "localhost"
|
||||||
|
port: 5432
|
||||||
|
# if socket-path is set, it will be used instead of host:port
|
||||||
|
# PostgreSQL uses the socket directory, not the socket file path
|
||||||
|
#socket-path: "/var/run/postgresql"
|
||||||
|
# PostgreSQL SSL/TLS configuration:
|
||||||
|
ssl-mode: "disable" # options: disable, require, verify-ca, verify-full
|
||||||
|
#ssl-cert: "/path/to/client-cert.pem"
|
||||||
|
#ssl-key: "/path/to/client-key.pem"
|
||||||
|
#ssl-root-cert: "/path/to/ca-cert.pem"
|
||||||
|
user: "ergo"
|
||||||
|
password: "hunter2"
|
||||||
|
history-database: "ergo_history"
|
||||||
|
# uri takes a postgresql:// (libpq) URI, overriding the above parameters if present:
|
||||||
|
# uri: "postgresql://ergo:hunter2@localhost/ergo_history"
|
||||||
|
timeout: 3s
|
||||||
|
max-conns: 4
|
||||||
|
# this may be necessary to prevent middleware from closing your connections:
|
||||||
|
#conn-max-lifetime: 180s
|
||||||
|
# application name shown in pg_stat_activity for operational visibility:
|
||||||
|
#application-name: "ergo"
|
||||||
|
# timeout for establishing initial connections to PostgreSQL:
|
||||||
|
#connect-timeout: 10s
|
||||||
|
|
||||||
|
# connection information for SQLite (currently only used for persistent history)
|
||||||
|
sqlite:
|
||||||
|
enabled: false
|
||||||
|
# path to the SQLite database file
|
||||||
|
database-path: "ergo_history.db"
|
||||||
|
# timeout when waiting for write lock
|
||||||
|
busy-timeout: 5s
|
||||||
|
# maximum concurrent connections
|
||||||
|
max-conns: 1
|
||||||
|
|
||||||
# languages config
|
# languages config
|
||||||
languages:
|
languages:
|
||||||
# whether to load languages
|
# whether to load languages
|
||||||
@ -800,6 +932,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
|
||||||
|
|
||||||
@ -819,7 +954,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):
|
||||||
@ -856,6 +991,7 @@ fakelag:
|
|||||||
"MARKREAD": 16
|
"MARKREAD": 16
|
||||||
"MONITOR": 1
|
"MONITOR": 1
|
||||||
"WHO": 4
|
"WHO": 4
|
||||||
|
"WEBPUSH": 1
|
||||||
|
|
||||||
# the roleplay commands are semi-standardized extensions to IRC that allow
|
# the roleplay commands are semi-standardized extensions to IRC that allow
|
||||||
# sending and receiving messages from pseudo-nicknames. this can be used either
|
# sending and receiving messages from pseudo-nicknames. this can be used either
|
||||||
@ -874,6 +1010,12 @@ roleplay:
|
|||||||
# add the real nickname, in parentheses, to the end of every roleplay message?
|
# add the real nickname, in parentheses, to the end of every roleplay message?
|
||||||
add-suffix: true
|
add-suffix: true
|
||||||
|
|
||||||
|
# allow customizing the NUH's sent for NPC and SCENE commands
|
||||||
|
# NPC: the first %s is the NPC name, the second is the user's real nick
|
||||||
|
#npc-nick-mask: "*%s*!%s@npc.fakeuser.invalid"
|
||||||
|
# SCENE: the %s is the client's real nick
|
||||||
|
#scene-nick-mask: "=Scene=!%s@npc.fakeuser.invalid"
|
||||||
|
|
||||||
# external services can integrate with the ircd using JSON Web Tokens (https://jwt.io).
|
# external services can integrate with the ircd using JSON Web Tokens (https://jwt.io).
|
||||||
# in effect, the server can sign a token attesting that the client is present on
|
# in effect, the server can sign a token attesting that the client is present on
|
||||||
# the server, is a member of a particular channel, etc.
|
# the server, is a member of a particular channel, etc.
|
||||||
@ -902,10 +1044,12 @@ history:
|
|||||||
# in your country and the countries of your users.
|
# in your country and the countries of your users.
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
# how many channel-specific events (messages, joins, parts) should be tracked per channel?
|
# if the in-memory backend is enabled for a channel, how many channel-specific events
|
||||||
|
# (messages, joins, parts) should be retained?
|
||||||
channel-length: 2048
|
channel-length: 2048
|
||||||
|
|
||||||
# how many direct messages and notices should be tracked per user?
|
# if the in-memory backend is enabled for a user, how many direct messages
|
||||||
|
# and notices should be retained?
|
||||||
client-length: 256
|
client-length: 256
|
||||||
|
|
||||||
# how long should we try to preserve messages?
|
# how long should we try to preserve messages?
|
||||||
@ -974,7 +1118,8 @@ history:
|
|||||||
|
|
||||||
# options to control how messages are stored and deleted:
|
# options to control how messages are stored and deleted:
|
||||||
retention:
|
retention:
|
||||||
# allow users to delete their own messages from history?
|
# allow users to delete their own messages from history,
|
||||||
|
# and channel operators to delete messages in their channel?
|
||||||
allow-individual-delete: false
|
allow-individual-delete: false
|
||||||
|
|
||||||
# if persistent history is enabled, create additional index tables,
|
# if persistent history is enabled, create additional index tables,
|
||||||
@ -1000,3 +1145,58 @@ history:
|
|||||||
# whether to allow customization of the config at runtime using environment variables,
|
# whether to allow customization of the config at runtime using environment variables,
|
||||||
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
|
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
|
||||||
allow-environment-overrides: true
|
allow-environment-overrides: true
|
||||||
|
|
||||||
|
# metadata support for setting key/value data on channels and nicknames.
|
||||||
|
metadata:
|
||||||
|
# can clients store metadata?
|
||||||
|
enabled: true
|
||||||
|
# if this is true, only server operators with the `metadata` capability can edit metadata:
|
||||||
|
operator-only-modification: false
|
||||||
|
# how many keys can a client subscribe to?
|
||||||
|
max-subs: 100
|
||||||
|
# how many keys can be stored per entity?
|
||||||
|
max-keys: 100
|
||||||
|
# rate limiting for client metadata updates, which are expensive to process
|
||||||
|
client-throttle:
|
||||||
|
enabled: true
|
||||||
|
duration: 2m
|
||||||
|
max-attempts: 10
|
||||||
|
|
||||||
|
# experimental support for mobile push notifications
|
||||||
|
# see the manual for potential security, privacy, and performance implications.
|
||||||
|
# DO NOT enable if you are running a Tor or I2P hidden service (i.e. one
|
||||||
|
# with no public IP listeners, only Tor/I2P listeners).
|
||||||
|
webpush:
|
||||||
|
# are push notifications enabled at all?
|
||||||
|
enabled: false
|
||||||
|
# request timeout for POST'ing the http notification
|
||||||
|
timeout: 10s
|
||||||
|
# delay sending the notification for this amount of time, then suppress it
|
||||||
|
# if the client sent MARKREAD to indicate that it was read on another device
|
||||||
|
delay: 0s
|
||||||
|
# subscriber field for the VAPID JWT authorization:
|
||||||
|
#subscriber: "https://your-website.com/"
|
||||||
|
# maximum number of push subscriptions per user
|
||||||
|
max-subscriptions: 4
|
||||||
|
# expiration time for a push subscription; it must be renewed within this time
|
||||||
|
# by the client reconnecting to IRC. we also detect whether the client is no longer
|
||||||
|
# successfully receiving push messages.
|
||||||
|
expiration: 14d
|
||||||
|
|
||||||
|
# HTTP API. we strongly recommend leaving this disabled unless you have a specific
|
||||||
|
# need for it.
|
||||||
|
api:
|
||||||
|
# is the API enabled at all?
|
||||||
|
enabled: false
|
||||||
|
# listen address:
|
||||||
|
listener: "127.0.0.1:8089"
|
||||||
|
# serve over TLS (strongly recommended if the listener is public):
|
||||||
|
#tls:
|
||||||
|
#cert: fullchain.pem
|
||||||
|
#key: privkey.pem
|
||||||
|
# one or more static bearer tokens accepted for HTTP bearer authentication.
|
||||||
|
# these must be strong, unique, high-entropy printable ASCII strings.
|
||||||
|
# to generate a new token, use `ergo gentoken` or:
|
||||||
|
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
bearer-tokens:
|
||||||
|
- "example"
|
||||||
|
|||||||
34
distrib/apparmor/ergo
Normal file
34
distrib/apparmor/ergo
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
include <tunables/global>
|
||||||
|
|
||||||
|
# Georg Pfuetzenreuter <georg+ergo@lysergic.dev>
|
||||||
|
# AppArmor confinement for ergo and ergo-ldap
|
||||||
|
|
||||||
|
profile ergo /usr/bin/ergo {
|
||||||
|
include <abstractions/base>
|
||||||
|
include <abstractions/consoles>
|
||||||
|
include <abstractions/nameservice>
|
||||||
|
|
||||||
|
/etc/ergo/ircd.{motd,yaml} r,
|
||||||
|
/etc/ssl/irc/{crt,key} r,
|
||||||
|
/etc/ssl/ergo/{crt,key} r,
|
||||||
|
/usr/bin/ergo mr,
|
||||||
|
/proc/sys/net/core/somaxconn r,
|
||||||
|
/sys/kernel/mm/transparent_hugepage/hpage_pmd_size r,
|
||||||
|
/usr/share/ergo/languages/{,*.lang.json,*.yaml} r,
|
||||||
|
owner /run/ergo/ircd.lock rwk,
|
||||||
|
owner /var/lib/ergo/ircd.db rw,
|
||||||
|
|
||||||
|
include if exists <local/ergo>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
profile ergo-ldap /usr/bin/ergo-ldap {
|
||||||
|
include <abstractions/openssl>
|
||||||
|
include <abstractions/ssl_certs>
|
||||||
|
|
||||||
|
/usr/bin/ergo-ldap rm,
|
||||||
|
/etc/ergo/ldap.yaml r,
|
||||||
|
|
||||||
|
include if exists <local/ergo-ldap>
|
||||||
|
|
||||||
|
}
|
||||||
29
distrib/bsd-rc/README.md
Normal file
29
distrib/bsd-rc/README.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
Ergo init script for bsd-rc
|
||||||
|
===
|
||||||
|
|
||||||
|
Written for and tested using FreeBSD.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
Copy the `ergo` file from this folder to `/etc/rc.d/ergo`,
|
||||||
|
permissions should be `555`.
|
||||||
|
|
||||||
|
You should create a system user for Ergo.
|
||||||
|
This script defaults to running Ergo as a user named `ergo`,
|
||||||
|
but that can be changed using `/etc/rc.conf`.
|
||||||
|
|
||||||
|
Here are all `rc.conf` variables and their defaults:
|
||||||
|
- `ergo_enable`, defaults to `NO`. Whether to run `ergo` at system start.
|
||||||
|
- `ergo_user`, defaults to `ergo`. Run using this user.
|
||||||
|
- `ergo_group`, defaults to `ergo`. Run using this group.
|
||||||
|
- `ergo_chdir`, defaults to `/var/db/ergo`. Path to the working directory for the server. Should be writable for `ergo_user`.
|
||||||
|
- `ergo_conf`, defaults to `/usr/local/etc/ergo/ircd.yaml`. Config file path. Make sure `ergo_user` can read it.
|
||||||
|
|
||||||
|
This script assumes ergo to be installed at `/usr/local/bin/ergo`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```shell
|
||||||
|
/etc/rc.d/ergo <command>
|
||||||
|
```
|
||||||
|
In addition to the obvious `start` and `stop` commands, this
|
||||||
|
script also has a `reload` command that sends `SIGHUP` to the Ergo process.
|
||||||
45
distrib/bsd-rc/ergo
Normal file
45
distrib/bsd-rc/ergo
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# PROVIDE: ergo
|
||||||
|
# REQUIRE: DAEMON
|
||||||
|
# KEYWORD: shutdown
|
||||||
|
|
||||||
|
#
|
||||||
|
# Add the following lines to /etc/rc.conf to enable Ergo
|
||||||
|
#
|
||||||
|
# ergo_enable (bool): Set to YES to enable ergo.
|
||||||
|
# Default is "NO".
|
||||||
|
# ergo_user (user): Set user to run ergo.
|
||||||
|
# Default is "ergo".
|
||||||
|
# ergo_group (group): Set group to run ergo.
|
||||||
|
# Default is "ergo".
|
||||||
|
# ergo_config (file): Set ergo config file path.
|
||||||
|
# Default is "/usr/local/etc/ergo/config.yaml".
|
||||||
|
# ergo_chdir (dir): Set ergo working directory
|
||||||
|
# Default is "/var/db/ergo".
|
||||||
|
|
||||||
|
. /etc/rc.subr
|
||||||
|
|
||||||
|
name=ergo
|
||||||
|
rcvar=ergo_enable
|
||||||
|
desc="Ergo IRCv3 server"
|
||||||
|
|
||||||
|
load_rc_config "$name"
|
||||||
|
|
||||||
|
: ${ergo_enable:=NO}
|
||||||
|
: ${ergo_user:=ergo}
|
||||||
|
: ${ergo_group:=ergo}
|
||||||
|
: ${ergo_chdir:=/var/db/ergo}
|
||||||
|
: ${ergo_conf:=/usr/local/etc/ergo/ircd.yaml}
|
||||||
|
|
||||||
|
# If you don't define a custom reload function,
|
||||||
|
# rc automagically sends SIGHUP to the process on reload.
|
||||||
|
# But you have to list reload as an extra_command for that.
|
||||||
|
extra_commands="reload"
|
||||||
|
|
||||||
|
procname="/usr/local/bin/${name}"
|
||||||
|
command=/usr/sbin/daemon
|
||||||
|
command_args="-S -T ${name} ${procname} run --conf ${ergo_conf}"
|
||||||
|
|
||||||
|
run_rc_command "$1"
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ certificates. To get a working ircd, all you need to do is run the image and
|
|||||||
expose the ports:
|
expose the ports:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker run --name ergo -d -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
|
docker run --init --name ergo -d -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start Ergo and listen on ports 6667 (plain text) and 6697 (TLS).
|
This will start Ergo and listen on ports 6667 (plain text) and 6697 (TLS).
|
||||||
@ -38,6 +38,11 @@ You should see a line similar to:
|
|||||||
Oper username:password is admin:cnn2tm9TP3GeI4vLaEMS
|
Oper username:password is admin:cnn2tm9TP3GeI4vLaEMS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
We recommend the use of `--init` (`init: true` in docker-compose) to solve an
|
||||||
|
edge case involving unreaped zombie processes when Ergo's script API is used
|
||||||
|
for authentication or IP validation. For more details, see
|
||||||
|
[krallin/tini#8](https://github.com/krallin/tini/issues/8).
|
||||||
|
|
||||||
## Persisting data
|
## Persisting data
|
||||||
|
|
||||||
Ergo has a persistent data store, used to keep account details, channel
|
Ergo has a persistent data store, used to keep account details, channel
|
||||||
@ -48,14 +53,14 @@ For example, to create a new docker volume and then mount it:
|
|||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker volume create ergo-data
|
docker volume create ergo-data
|
||||||
docker run -d -v ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
|
docker run --init --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 -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
|
||||||
@ -80,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).
|
||||||
|
|||||||
@ -2,6 +2,7 @@ version: "3.8"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
ergo:
|
ergo:
|
||||||
|
init: true
|
||||||
image: ghcr.io/ergochat/ergo:stable
|
image: ghcr.io/ergochat/ergo:stable
|
||||||
ports:
|
ports:
|
||||||
- "6667:6667/tcp"
|
- "6667:6667/tcp"
|
||||||
|
|||||||
181
docs/API.md
Normal file
181
docs/API.md
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
__ __ ______ ___ ______ ___
|
||||||
|
__/ // /_/ ____/ __ \/ ____/ __ \
|
||||||
|
/_ // __/ __/ / /_/ / / __/ / / /
|
||||||
|
/_ // __/ /___/ _, _/ /_/ / /_/ /
|
||||||
|
/_//_/ /_____/_/ |_|\____/\____/
|
||||||
|
|
||||||
|
Ergo IRCd API Documentation
|
||||||
|
https://ergo.chat/
|
||||||
|
|
||||||
|
_Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn@cs.stanford.edu>_
|
||||||
|
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Ergo has an experimental HTTP API. Some general information about the API:
|
||||||
|
|
||||||
|
1. All requests to the API are via POST.
|
||||||
|
1. All requests to the API are authenticated via bearer authentication. This is a header named `Authorization` with the value `Bearer <token>`. A list of valid tokens is hardcoded in the Ergo config. Future versions of Ergo may allow additional validation schemes for tokens.
|
||||||
|
1. The request parameters are sent as JSON in the POST body.
|
||||||
|
1. Any status code other than 200 is an error response; the response body is undefined in this case (likely human-readable text for debugging).
|
||||||
|
1. A 200 status code indicates successful execution of the request. The response body will be JSON and may indicate application-level success or failure (typically via the `success` field, which takes a boolean value).
|
||||||
|
|
||||||
|
API endpoints are versioned (currently all endpoints have a `/v1/` path prefix). Backwards-incompatible updates will most likely take the form of endpoints with new names, or an increased version prefix. Any exceptions to this will be specifically documented in the changelog.
|
||||||
|
|
||||||
|
All API endpoints should be considered highly privileged. Bearer tokens should be kept secret. Access to the API should be either over a trusted link (like loopback) or secured via verified TLS. See the `api` section of `default.yaml` for examples of how to configure this.
|
||||||
|
|
||||||
|
Here's an example of how to test an API configured to run over loopback TCP in plaintext:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -d '{"accountName": "invalidaccountname", "passphrase": "invalidpassphrase"}' -H 'Authorization: Bearer EYBbXVilnumTtfn4A9HE8_TiKLGWEGylre7FG6gEww0' -v http://127.0.0.1:8089/v1/check_auth
|
||||||
|
```
|
||||||
|
|
||||||
|
This returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"success":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
=========
|
||||||
|
|
||||||
|
`/v1/check_auth`
|
||||||
|
----------------
|
||||||
|
|
||||||
|
This endpoint verifies the credentials of a NickServ account; this allows Ergo to be used as the source of truth for authentication by another system. The request is a JSON object with fields:
|
||||||
|
|
||||||
|
* `accountName`: string, name of the account
|
||||||
|
* `passphrase`: string, alleged passphrase of the account
|
||||||
|
* `certfp`: string, alleged certificate fingerprint (hex-encoded SHA-256 checksum of the decoded raw certificate) associated with the account
|
||||||
|
|
||||||
|
Each individual field is optional, since a user may be authenticated either by account-passphrase pair or by certificate.
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the credentials provided were valid
|
||||||
|
* `accountName`: canonical, case-unfolded version of the account name
|
||||||
|
|
||||||
|
`/v1/defcon`
|
||||||
|
------------
|
||||||
|
|
||||||
|
This endpoint can be used to view or modify the DEFCON level (see `/helpop defcon` for details). If the request is empty, the existing level is returned. To change the level, send a JSON object with fields:
|
||||||
|
|
||||||
|
* `defcon`: integer, desired new value of the DEFCON setting (between 5 for normal operation and 1 for the most restrictive)
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `defcon`: integer, current (or new) value of the DEFCON setting
|
||||||
|
|
||||||
|
`/v1/list`
|
||||||
|
----------
|
||||||
|
|
||||||
|
This endpoint returns a list of channels that exist on the network. The request body is ignored and can be empty.
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the request was successful
|
||||||
|
* `channels`: a list of channel objects, as described below
|
||||||
|
|
||||||
|
Each channel object has fields:
|
||||||
|
|
||||||
|
* `name`: canonical name of the channel without case-normalization
|
||||||
|
* `hasKey`: boolean, whether the channel has a key set with the `+k` mode
|
||||||
|
* `inviteOnly`: boolean, whether the channel has the `+i` invite-only mode set
|
||||||
|
* `secret`: boolean, whether the channel has the `+s` secret mode set (and would be hidden from an unprivileged `LIST` command)
|
||||||
|
* `userCount`: integer, number of users in the channel
|
||||||
|
* `topic`: string, channel topic
|
||||||
|
* `topicSetAt`: string, time the topic was last updated (in ISO8601 format)
|
||||||
|
* `createdAt`: string, time the channel was created (in ISO8601 format)
|
||||||
|
* `registered`: boolean, whether the channel is registered
|
||||||
|
* `owner`: string, account name of the registered owner if the channel is registered
|
||||||
|
* `registeredAt`: string, registration date/time of the channel (in ISO8601 format) if it is registered
|
||||||
|
|
||||||
|
|
||||||
|
`/v1/ns/info`
|
||||||
|
-------------
|
||||||
|
|
||||||
|
This endpoint fetches account details and returns them as JSON. The request is a JSON object with fields:
|
||||||
|
|
||||||
|
* `accountName`: string, name of the account
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the account exists or not
|
||||||
|
* `accountName`: canonical, case-unfolded version of the account name
|
||||||
|
* `email`: email address of the account provided
|
||||||
|
* `registeredAt`: string, registration date/time of the account (in ISO8601 format)
|
||||||
|
* `channels`: array of strings, list of channels the account is registered on or associated with
|
||||||
|
|
||||||
|
Note: this endpoint was previously named `/v1/account_details`. The old name is still accepted for backwards compatibility.
|
||||||
|
|
||||||
|
`/v1/ns/list`
|
||||||
|
-------------
|
||||||
|
|
||||||
|
This endpoint fetches a list of all accounts. The request body is ignored and can be empty.
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the request succeeded
|
||||||
|
* `accounts`: array of objects, each with fields:
|
||||||
|
* `success`: boolean, whether this individual account query succeeded
|
||||||
|
* `accountName`: string, canonical, case-unfolded version of the account name
|
||||||
|
* `totalCount`: integer, total number of accounts returned
|
||||||
|
|
||||||
|
Note: this endpoint was previously named `/v1/account_list`. The old name is still accepted for backwards compatibility.
|
||||||
|
|
||||||
|
`/v1/ns/passwd`
|
||||||
|
---------------
|
||||||
|
|
||||||
|
This endpoint changes the password of an existing NickServ account. The request is a JSON object with fields:
|
||||||
|
|
||||||
|
* `accountName`: string, name of the account
|
||||||
|
* `passphrase`: string, new passphrase for the account
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the password change succeeded
|
||||||
|
* `errorCode`: string, optional, machine-readable description of the error. Possible values include: `ACCOUNT_DOES_NOT_EXIST`, `INVALID_PASSPHRASE`, `CREDENTIALS_EXTERNALLY_MANAGED`, `UNKNOWN_ERROR`.
|
||||||
|
|
||||||
|
`/v1/ns/saregister`
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
This endpoint registers an account in NickServ, with the same semantics as `NS SAREGISTER`. The request is a JSON object with fields:
|
||||||
|
|
||||||
|
* `accountName`: string, name of the account
|
||||||
|
* `passphrase`: string, passphrase of the account
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the account creation succeeded
|
||||||
|
* `errorCode`: string, optional, machine-readable description of the error. Possible values include: `ACCOUNT_EXISTS`, `INVALID_PASSPHRASE`, `UNKNOWN_ERROR`.
|
||||||
|
* `error`: string, optional, human-readable description of the failure.
|
||||||
|
|
||||||
|
Note: this endpoint was previously named `/v1/saregister`. The old name is still accepted for backwards compatibility.
|
||||||
|
|
||||||
|
`/v1/rehash`
|
||||||
|
------------
|
||||||
|
|
||||||
|
This endpoint rehashes the server (i.e. reloads the configuration file, TLS certificates, and other associated data). The body is ignored. The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: boolean, indicates whether the rehash was successful
|
||||||
|
* `error`: string, optional, human-readable description of the failure
|
||||||
|
|
||||||
|
`/v1/status`
|
||||||
|
------------
|
||||||
|
|
||||||
|
This endpoint returns status information about the running Ergo server. The request body is ignored and can be empty.
|
||||||
|
|
||||||
|
The response is a JSON object with fields:
|
||||||
|
|
||||||
|
* `success`: whether the request succeeded
|
||||||
|
* `version`: string, Ergo server version string
|
||||||
|
* `go_version`: string, version of Go runtime used
|
||||||
|
* `start_time`: string, server start time in ISO8601 format
|
||||||
|
* `users`: object with fields:
|
||||||
|
* `total`: total number of users connected
|
||||||
|
* `invisible`: number of invisible users
|
||||||
|
* `operators`: number of operators connected
|
||||||
|
* `unknown`: number of users with unknown status
|
||||||
|
* `max`: maximum number of users seen connected at once
|
||||||
|
* `channels`: integer, number of channels currently active
|
||||||
|
* `servers`: integer, number of servers connected in the network
|
||||||
45
docs/BUILD.md
Normal file
45
docs/BUILD.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
__ __ ______ ___ ______ ___
|
||||||
|
__/ // /_/ ____/ __ \/ ____/ __ \
|
||||||
|
/_ // __/ __/ / /_/ / / __/ / / /
|
||||||
|
/_ // __/ /___/ _, _/ /_/ / /_/ /
|
||||||
|
/_//_/ /_____/_/ |_|\____/\____/
|
||||||
|
|
||||||
|
Ergo Build Guide
|
||||||
|
https://ergo.chat/
|
||||||
|
|
||||||
|
_Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn@cs.stanford.edu>_
|
||||||
|
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
This guide is for building Ergo from source. You can also obtain a pre-built release binary from our [GitHub page](https://github.com/ergochat/ergo/releases).
|
||||||
|
|
||||||
|
# Prerequisites
|
||||||
|
|
||||||
|
You will need an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Use the latest version available. (As of this writing, only Google's Go distribution is supported, since `gccgo` lacks support for current language features.) Check the output of `go version` to ensure it was installed correctly.
|
||||||
|
|
||||||
|
You will need to either clone the repository from GitHub at [https://github.com/ergochat/ergo], or obtain a source tarball from our releases page on GitHub.
|
||||||
|
|
||||||
|
# What to build
|
||||||
|
|
||||||
|
Typical deployments should build the `stable` branch, which points to the latest stable release. In general, `stable` should coincide with the latest published tag that is not designated as a beta or release candidate (for example, `v2.7.0-rc1` was an unstable release candidate and `v2.7.0` was the corresponding stable release), so you can also identify the latest stable release tag on the [releases page](https://github.com/ergochat/ergo/releases) and build that.
|
||||||
|
|
||||||
|
The `master` branch is not recommended for production use since it may contain bugs, and because the forwards compatibility guarantees for the config file and the database that apply to releases do not apply to master. That is to say, running master may result in changes to your database that end up being incompatible with future versions of Ergo.
|
||||||
|
|
||||||
|
# Build tags and options
|
||||||
|
|
||||||
|
By default, Ergo is built with cgo disabled, producing a fully statically linked binary. You can disable this with `export CGO_ENABLED=1` before running `make`.
|
||||||
|
|
||||||
|
Ergo can be cross-compiled using [standard Go environment variables](https://go.dev/doc/install/source#environment), e.g. `GOOS=linux GOARCH=arm GOARM=v6 make build` will build an `ergo` binary suitable for a 32-bit Raspberry Pi.
|
||||||
|
|
||||||
|
The default Ergo binary (built with `make` or `make build`) includes support for all optional features. Each optional feature is controlled via a separate build tag; to override the build tags, pass the environment variable `ERGO_BUILD_TAGS` with a space-separated list of tags. (For example, for parity with v2.17.0 and earlier, you can run `ERGO_BUILD_TAGS="i18n mysql" make`. Passing the empty string disables all optional features.)
|
||||||
|
|
||||||
|
The supported build tags are:
|
||||||
|
|
||||||
|
* `i18n` enables support for non-ASCII casemappings (allowing Unicode in nicknames and channel names). (This was a default feature in Ergo v2.17.0 and earlier, but was not enabled by default at runtime. See the `server.casemapping` value of the config file.)
|
||||||
|
* `mysql` enables support for MySQL as a persistent history backend. (This was a default feature in v2.17.0 and earlier.)
|
||||||
|
* `postgresql` enables support for PostgreSQL as a persistent history backend.
|
||||||
|
* `sqlite` enables support for SQLite as a persistent history backend.
|
||||||
|
|
||||||
|
`sqlite` is particularly memory-intensive to compile (but not to run), so if you're building Ergo for a memory-constrained environment, you may want to consider cross-compilation.
|
||||||
115
docs/MANUAL.md
115
docs/MANUAL.md
@ -41,9 +41,10 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
|
|||||||
- [Language](#language)
|
- [Language](#language)
|
||||||
- [Multiclient ("Bouncer")](#multiclient-bouncer)
|
- [Multiclient ("Bouncer")](#multiclient-bouncer)
|
||||||
- [History](#history)
|
- [History](#history)
|
||||||
- [Persistent history with MySQL](#persistent-history-with-mysql)
|
- [Persistent history](#persistent-history)
|
||||||
- [IP cloaking](#ip-cloaking)
|
- [IP cloaking](#ip-cloaking)
|
||||||
- [Moderation](#moderation)
|
- [Moderation](#moderation)
|
||||||
|
- [Push notifications](#push-notifications)
|
||||||
- [Frequently Asked Questions](#frequently-asked-questions)
|
- [Frequently Asked Questions](#frequently-asked-questions)
|
||||||
- [IRC over TLS](#irc-over-tls)
|
- [IRC over TLS](#irc-over-tls)
|
||||||
- [Redirect from plaintext to TLS](#how-can-i-redirect-users-from-plaintext-to-tls)
|
- [Redirect from plaintext to TLS](#how-can-i-redirect-users-from-plaintext-to-tls)
|
||||||
@ -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`.
|
||||||
@ -175,6 +179,9 @@ Ergo can also be configured using environment variables, using the following tec
|
|||||||
|
|
||||||
However, settings that were overridden using this technique cannot be rehashed --- changing them will require restarting the server.
|
However, settings that were overridden using this technique cannot be rehashed --- changing them will require restarting the server.
|
||||||
|
|
||||||
|
Due to implementation details, this technique has some limitations. Here are the known issues:
|
||||||
|
|
||||||
|
1. `accounts.auth-script` and `server.ip-check-script` do not work as expected (see [#2275](https://github.com/ergochat/ergo/issues/2275) for workarounds).
|
||||||
|
|
||||||
## Productionizing with systemd
|
## Productionizing with systemd
|
||||||
|
|
||||||
@ -182,8 +189,8 @@ The recommended way to operate ergo as a service on Linux is via systemd. This p
|
|||||||
|
|
||||||
The only major distribution that currently packages Ergo is Arch Linux; the aforementioned AUR package includes a systemd unit file. However, it should be fairly straightforward to set up a productionized Ergo on any Linux distribution. Here's a quickstart guide for Debian/Ubuntu:
|
The only major distribution that currently packages Ergo is Arch Linux; the aforementioned AUR package includes a systemd unit file. However, it should be fairly straightforward to set up a productionized Ergo on any Linux distribution. Here's a quickstart guide for Debian/Ubuntu:
|
||||||
|
|
||||||
1. Create a dedicated, unprivileged role user who will own the ergo process and all its associated files: `adduser --system --group ergo`. This user now has a home directory at `/home/ergo`. To prevent other users from viewing Ergo's configuration file, database, and certificates, restrict the permissions on the home directory: `chmod 0700 /home/ergo`.
|
1. Create a dedicated, unprivileged role user who will own the ergo process and all its associated files: `adduser --system --group --home=/home/ergo ergo`. This user now has a home directory at `/home/ergo`. To prevent other users from viewing Ergo's configuration file, database, and certificates, restrict the permissions on the home directory: `chmod 0700 /home/ergo`.
|
||||||
1. Copy the executable binary `ergo`, the config file `ircd.yaml`, the database `ircd.db`, and the self-signed TLS certificate (`fullchain.pem` and `privkey.pem`) to `/home/ergo`. (If you don't have an `ircd.db`, it will be auto-created as `/home/ergo/ircd.db` on first launch.) Ensure that they are all owned by the new ergo role user: `sudo chown ergo:ergo /home/ergo/*`. Ensure that the configuration file logs to stderr.
|
1. Copy the executable binary `ergo`, the config file `ircd.yaml`, the database `ircd.db`, and the self-signed TLS certificate (`fullchain.pem` and `privkey.pem`) to `/home/ergo`. (If you don't have an `ircd.db`, it will be auto-created as `/home/ergo/ircd.db` on first launch.) Ensure that they are all owned by the new ergo role user: `sudo chown -R ergo:ergo /home/ergo`. Ensure that the configuration file logs to stderr.
|
||||||
1. Install our example [ergo.service](https://github.com/ergochat/ergo/blob/stable/distrib/systemd/ergo.service) file to `/etc/systemd/system/ergo.service`.
|
1. Install our example [ergo.service](https://github.com/ergochat/ergo/blob/stable/distrib/systemd/ergo.service) file to `/etc/systemd/system/ergo.service`.
|
||||||
1. Enable and start the new service with the following commands:
|
1. Enable and start the new service with the following commands:
|
||||||
1. `systemctl daemon-reload`
|
1. `systemctl daemon-reload`
|
||||||
@ -422,9 +429,32 @@ Unfortunately, client support for history playback is still patchy. In descendin
|
|||||||
1. You can autoreplay a fixed number of lines (e.g., 25) each time you join a channel using `/msg NickServ set autoreplay-lines 25`.
|
1. You can autoreplay a fixed number of lines (e.g., 25) each time you join a channel using `/msg NickServ set autoreplay-lines 25`.
|
||||||
|
|
||||||
|
|
||||||
## Persistent history with MySQL
|
## Persistent history
|
||||||
|
|
||||||
On most Linux and POSIX systems, it's straightforward to set up MySQL (or MariaDB) as a backend for persistent history. This increases the amount of history that can be stored, and ensures that message data will be retained on server restart (you can still use the configuration options to set a time limit for retention). Here's a quick start guide for Ubuntu based on [Digital Ocean's documentation](https://www.digitalocean.com/community/tutorials/how-to-install-mysql-on-ubuntu-20-04):
|
Persistent history means storing chat history (messages, but also events like JOINs and PARTs) on disk. This increases the amount of history that can be stored, and also ensures that message data will be retained on server restart (you can still use the configuration options to set a time limit for retention). Ergo supports three backends for persistent history: MySQL, PostgreSQL, and SQLite. If you have a default build of Ergo (for example, a release build from our GitHub page, or our official Docker image), all three backends are available.
|
||||||
|
|
||||||
|
To configure persistent history, you must set `history.persistent.enabled` to `true` in the Ergo config file. You may want to adjust other options in the `history` section at this time. Then you must additionally enable and configure one of the backends. Here are per-backend instructions:
|
||||||
|
|
||||||
|
### SQLite
|
||||||
|
|
||||||
|
SQLite is the easiest backend to enable; it's an embedded database that runs inside the Ergo process, without needing to talk to an external database server. Find `datastore.sqlite` in your config (or add it, following an up-to-date `default.yaml` as a guide):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sqlite:
|
||||||
|
enabled: true
|
||||||
|
# path to the SQLite database file
|
||||||
|
database-path: "ergo_history.db"
|
||||||
|
# timeout when waiting for write lock
|
||||||
|
busy-timeout: 5s
|
||||||
|
# maximum concurrent connections
|
||||||
|
max-conns: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates an on-disk file `ergo_history.db` for the history storage, by default in the same working directory as the Ergo process. We believe SQLite should scale to the needs of most Ergo deployments (in our initial benchmarks, there is a write bottleneck of approximately 1K messages/events per second).
|
||||||
|
|
||||||
|
### MySQL
|
||||||
|
|
||||||
|
Here's a quick start guide for MySQL on Ubuntu based on [Digital Ocean's documentation](https://www.digitalocean.com/community/tutorials/how-to-install-mysql-on-ubuntu-20-04). (Ergo is also compatible with MariaDB; a compatible implementation is available on most Linux and POSIX platforms.)
|
||||||
|
|
||||||
1. Install the `mysql-server` package
|
1. Install the `mysql-server` package
|
||||||
1. Run `mysql_secure_installation` as root; this corrects some insecure package defaults
|
1. Run `mysql_secure_installation` as root; this corrects some insecure package defaults
|
||||||
@ -445,6 +475,10 @@ On most Linux and POSIX systems, it's straightforward to set up MySQL (or MariaD
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
If you don't already have a PostgreSQL database, follow [Digital Ocean's quick start guide](https://www.digitalocean.com/community/tutorials/how-to-install-postgresql-on-ubuntu-22-04-quickstart) to set one up, then edit `datastore.postgresql` in the Ergo config file with `enabled: true` and your database parameters.
|
||||||
|
|
||||||
|
|
||||||
## IP cloaking
|
## IP cloaking
|
||||||
|
|
||||||
@ -482,6 +516,19 @@ These techniques require operator privileges: `UBAN` requires the `ban` operator
|
|||||||
For channel operators, `/msg ChanServ HOWTOBAN #channel nickname` will provide similar information about the best way to ban a user from a channel.
|
For channel operators, `/msg ChanServ HOWTOBAN #channel nickname` will provide similar information about the best way to ban a user from a channel.
|
||||||
|
|
||||||
|
|
||||||
|
## Push notifications
|
||||||
|
|
||||||
|
Ergo now has experimental support for push notifications via the [draft/webpush](https://github.com/ircv3/ircv3-specifications/pull/471) IRCv3 specification. Support for push notifications is disabled by default; operators can enable it by setting `webpush.enabled` to `true` in the configuration file. This has security, privacy, and performance implications:
|
||||||
|
|
||||||
|
* If push notifications are enabled, Ergo will send HTTP POST requests to HTTP endpoints of the user's choosing. Although the user has limited control over the POST body (since it is encrypted with random key material), and Ergo disallows requests to local or internal IP addresses, this may potentially impact the IP reputation of the Ergo host, or allow an attacker to probe endpoints that whitelist the Ergo host's IP address.
|
||||||
|
* Push notifications result in the disclosure of metadata (that the user received a message, and the approximate time of the message) to third-party messaging infrastructure. In the typical case, this will include a push endpoint controlled by the application vendor, plus the push infrastructure controlled by Apple or Google.
|
||||||
|
* The message contents (including the sender's identity) are protected by [encryption](https://datatracker.ietf.org/doc/html/rfc8291) between the server and the user's endpoint device. However, the encryption algorithm is not forward-secret (a long-term private key is stored on the user's device) or post-quantum (the server retains a copy of the corresponding elliptic curve public key).
|
||||||
|
* Push notifications are relatively expensive to process, and may increase the impact of spam or denial-of-service attacks on the Ergo server.
|
||||||
|
* Push notifications negate the anonymization provided by Tor and I2P; an Ergo instance intended to run as a Tor onion service ("hidden service") or exclusively behind an I2P address must disable them in the Ergo configuration file.
|
||||||
|
|
||||||
|
Operators and end users are invited to share feedback about push notifications, either via the project issue tracker or the support channel. Note that in order to receive push notifications, the user must be logged in with always-on enabled, and must be using a client (e.g. Goguma) that supports them.
|
||||||
|
|
||||||
|
|
||||||
-------------------------------------------------------------------------------------------
|
-------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@ -510,18 +557,23 @@ If your client or bot is failing to connect to Ergo, here are some things to che
|
|||||||
|
|
||||||
## Why can't I oper?
|
## Why can't I oper?
|
||||||
|
|
||||||
If you try to oper unsuccessfully, Ergo will disconnect you from the network. If you're unable to oper, here are some things to double-check:
|
If your `OPER` command fails, check your server logs for more information. Here are some general issues to double-check:
|
||||||
|
|
||||||
1. Did you correctly generate the hashed password with `ergo genpasswd`?
|
1. Did you correctly generate the hashed password with `ergo genpasswd`?
|
||||||
1. Did you add the password hash to the correct config file, then save the file?
|
1. Did you add the password hash to the correct config file, then save the file?
|
||||||
1. Did you rehash or restart Ergo after saving the file?
|
1. Did you rehash or restart Ergo after saving the file?
|
||||||
|
1. Does your password contain spaces or non-ASCII characters? Although such passwords are theoretically compatible with Ergo, they are likely to cause problems with your client. (The period character `.` is an acceptable alternative separator if your password is based on randomly chosen words.)
|
||||||
|
|
||||||
The config file accepts hashed passwords, not plaintext passwords. You must run `ergo genpasswd`, type your actual password in, and then receive a hashed blob back (it will look like `$2a$04$GvCFlShLZQjId3dARzwOWu9Nvq6lndXINw2Sdm6mUcwxhtx1U/hIm`). Enter that into the relevant `opers` block in your config file, then save the file.
|
The config file accepts hashed passwords, not plaintext passwords. You must run `ergo genpasswd`, type your actual password in, and then receive a hashed blob back (it will look like `$2a$04$GvCFlShLZQjId3dARzwOWu9Nvq6lndXINw2Sdm6mUcwxhtx1U/hIm`). Enter that into the relevant `opers` block in your config file, then save the file.
|
||||||
|
|
||||||
Although it's theoretically possible to use an operator password that contains spaces, your client may not support it correctly, so it's advisable to choose a password without spaces. (The period character `.` is an acceptable alternative separator if your password is based on randomly chosen words.)
|
|
||||||
|
|
||||||
After that, you must rehash or restart Ergo to apply the config change. If a rehash didn't accomplish the desired effects, you might want to try a restart instead.
|
After that, you must rehash or restart Ergo to apply the config change. If a rehash didn't accomplish the desired effects, you might want to try a restart instead.
|
||||||
|
|
||||||
|
If you're still having problems, your client or bouncer may be mangling the OPER command. You can try connecting to Ergo directly via the `nc` ("netcat") command to test this:
|
||||||
|
|
||||||
|
1. From the machine where Ergo is running, run `nc -v 127.0.0.1 6667`. (If you are using Docker, you will first need to get a shell inside the Docker container, e.g. with `docker exec -it $CONTAINER_ID /bin/sh`.)
|
||||||
|
1. Type `NICK unique_nickname`, press enter, type `USER u s e r`, and press enter. You may need to retry with a different nickname if the first one is in use.
|
||||||
|
1. Once you see a burst of lines starting with an `001` command, indicating a successful connection, type: `OPER <opername> <password>` and press enter.
|
||||||
|
1. If you see a successful response including the `381` command, this indicates that your password was accepted by Ergo and the problem is with your client or bouncer setup. If you see an error response, then there is an issue with your password or configuration file.
|
||||||
|
|
||||||
## Why is Ergo ignoring my ident response / USER command?
|
## Why is Ergo ignoring my ident response / USER command?
|
||||||
|
|
||||||
@ -623,6 +675,8 @@ Many clients do not have this support. However, you can designate port 6667 as a
|
|||||||
|
|
||||||
Ergo supports the use of reverse proxies (such as nginx, or a Kubernetes [LoadBalancer](https://kubernetes.io/docs/concepts/services-networking/service/#loadbalancer)) that sit between it and the client. In these deployments, the [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) is used to pass the end user's IP through to Ergo. These proxies can be used to terminate TLS externally to Ergo, e.g., if you need to support versions of the TLS protocol that are not implemented natively by Go, or if you want to consolidate your certificate management into a single nginx instance.
|
Ergo supports the use of reverse proxies (such as nginx, or a Kubernetes [LoadBalancer](https://kubernetes.io/docs/concepts/services-networking/service/#loadbalancer)) that sit between it and the client. In these deployments, the [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) is used to pass the end user's IP through to Ergo. These proxies can be used to terminate TLS externally to Ergo, e.g., if you need to support versions of the TLS protocol that are not implemented natively by Go, or if you want to consolidate your certificate management into a single nginx instance.
|
||||||
|
|
||||||
|
### IRC Sockets
|
||||||
|
|
||||||
The first step is to add the reverse proxy's IP to `proxy-allowed-from` and `ip-limits.exempted`. (Use `localhost` to exempt all loopback IPs and Unix domain sockets.)
|
The first step is to add the reverse proxy's IP to `proxy-allowed-from` and `ip-limits.exempted`. (Use `localhost` to exempt all loopback IPs and Unix domain sockets.)
|
||||||
|
|
||||||
After that, there are two possibilities:
|
After that, there are two possibilities:
|
||||||
@ -638,6 +692,10 @@ After that, there are two possibilities:
|
|||||||
proxy: true
|
proxy: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Websockets through HTTP reverse proxies
|
||||||
|
|
||||||
|
Ergo will honor the `X-Forwarded-For` headers on incoming websocket connections, if the peer IP address appears in `proxy-allowed-from`. For these connections, set `proxy: false`, or omit the `proxy` option.
|
||||||
|
|
||||||
|
|
||||||
## Client certificates
|
## Client certificates
|
||||||
|
|
||||||
@ -1017,6 +1075,24 @@ or with Gamja, create a new `config.json` (in the base directory of the Gamja in
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On Apache 2.4.47 or higher, websocket proxying can be configured with:
|
||||||
|
|
||||||
|
```
|
||||||
|
RequestHeader setifempty X-Forwarded-Proto https
|
||||||
|
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyPass /webirc http://127.0.0.1:8067 upgrade=websocket
|
||||||
|
ProxyPassReverse /webirc http://127.0.0.1:8067
|
||||||
|
```
|
||||||
|
|
||||||
|
On Caddy, websocket proxying can be configured with:
|
||||||
|
|
||||||
|
```
|
||||||
|
handle_path /webirc {
|
||||||
|
reverse_proxy 127.0.0.1:8067
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Migrating from Anope or Atheme
|
## Migrating from Anope or Atheme
|
||||||
|
|
||||||
You can import user and channel registrations from an Anope or Atheme database into a new Ergo database (not all features are supported). Use the following steps:
|
You can import user and channel registrations from an Anope or Atheme database into a new Ergo database (not all features are supported). Use the following steps:
|
||||||
@ -1031,9 +1107,12 @@ You can import user and channel registrations from an Anope or Atheme database i
|
|||||||
|
|
||||||
## Hybrid Open Proxy Monitor (HOPM)
|
## Hybrid Open Proxy Monitor (HOPM)
|
||||||
|
|
||||||
[hopm](https://github.com/ircd-hybrid/hopm) can be used to monitor your server for connections from open proxies, then automatically ban them. To configure hopm to work with Ergo, add operator blocks like this to your Ergo config file, which grant hopm the necessary privileges:
|
[hopm](https://github.com/ircd-hybrid/hopm) can be used to monitor your server for connections from open proxies, then automatically ban them. To configure hopm to work with Ergo, configure `server.initial_notice` and add operator blocks like this to your Ergo config file, which grant hopm the necessary privileges:
|
||||||
|
|
||||||
````yaml
|
````yaml
|
||||||
|
server:
|
||||||
|
initial-notice: "Welcome to the Ergo IRC server"
|
||||||
|
|
||||||
# operator classes
|
# operator classes
|
||||||
oper-classes:
|
oper-classes:
|
||||||
# hopm
|
# hopm
|
||||||
@ -1068,6 +1147,9 @@ opers:
|
|||||||
Then configure hopm like this:
|
Then configure hopm like this:
|
||||||
|
|
||||||
````
|
````
|
||||||
|
/* replace with the exact notice your server sends on first connection */
|
||||||
|
target_string = ":ergo.test NOTICE * :*** Welcome to the Ergo IRC server"
|
||||||
|
|
||||||
/* ergo */
|
/* ergo */
|
||||||
connregex = ".+-.+CONNECT.+-.+ Client Connected \\[([^ ]+)\\] \\[u:([^ ]+)\\] \\[h:([^ ]+)\\] \\[ip:([^ ]+)\\] .+";
|
connregex = ".+-.+CONNECT.+-.+ Client Connected \\[([^ ]+)\\] \\[u:([^ ]+)\\] \\[h:([^ ]+)\\] \\[ip:([^ ]+)\\] .+";
|
||||||
|
|
||||||
@ -1096,6 +1178,7 @@ Tor provides end-to-end encryption for onion services, so there's no need to ena
|
|||||||
The second way is to run Ergo as a true hidden service, where the server's actual IP address is a secret. This requires hardening measures on the Ergo side:
|
The second way is to run Ergo as a true hidden service, where the server's actual IP address is a secret. This requires hardening measures on the Ergo side:
|
||||||
|
|
||||||
* Ergo should not accept any connections on its public interfaces. You should remove any listener that starts with the address of a public interface, or with `:`, which means "listen on all available interfaces". You should listen only on `127.0.0.1:6667` and a Unix domain socket such as `/hidden_service_sockets/ergo_tor_sock`.
|
* Ergo should not accept any connections on its public interfaces. You should remove any listener that starts with the address of a public interface, or with `:`, which means "listen on all available interfaces". You should listen only on `127.0.0.1:6667` and a Unix domain socket such as `/hidden_service_sockets/ergo_tor_sock`.
|
||||||
|
* Push notifications will reveal the server's true IP address, so they must be disabled; set `webpush.enabled` to `false`.
|
||||||
* In this mode, it is especially important that all operator passwords are strong and all operators are trusted (operators have a larger attack surface to deanonymize the server).
|
* In this mode, it is especially important that all operator passwords are strong and all operators are trusted (operators have a larger attack surface to deanonymize the server).
|
||||||
* Onion services are at risk of being deanonymized if a client can trick the server into performing a non-Tor network request. Ergo should not perform any such requests (such as hostname resolution or ident lookups) in response to input received over a correctly configured Tor listener. However, Ergo has not been thoroughly audited against such deanonymization attacks --- therefore, Ergo should be deployed with additional sandboxing to protect against this:
|
* Onion services are at risk of being deanonymized if a client can trick the server into performing a non-Tor network request. Ergo should not perform any such requests (such as hostname resolution or ident lookups) in response to input received over a correctly configured Tor listener. However, Ergo has not been thoroughly audited against such deanonymization attacks --- therefore, Ergo should be deployed with additional sandboxing to protect against this:
|
||||||
* Ergo should run with no direct network connectivity, e.g., by running in its own Linux network namespace. systemd implements this with the [PrivateNetwork](https://www.freedesktop.org/software/systemd/man/systemd.exec.html) configuration option: add `PrivateNetwork=true` to Ergo's systemd unit file.
|
* Ergo should run with no direct network connectivity, e.g., by running in its own Linux network namespace. systemd implements this with the [PrivateNetwork](https://www.freedesktop.org/software/systemd/man/systemd.exec.html) configuration option: add `PrivateNetwork=true` to Ergo's systemd unit file.
|
||||||
@ -1118,6 +1201,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
|
||||||
|
|
||||||
@ -1125,6 +1218,10 @@ ZNC 1.6.x (still pretty common in distros that package old versions of IRC softw
|
|||||||
|
|
||||||
Ergo can emulate certain capabilities of the ZNC bouncer for the benefit of clients, in particular the third-party [playback](https://wiki.znc.in/Playback) module. This enables clients with specific support for ZNC to receive selective history playback automatically. To configure this in [Textual](https://www.codeux.com/textual/), go to "Server properties", select "Vendor specific", uncheck "Do not automatically join channels on connect", and check "Only play back messages you missed". Other clients with support are listed on ZNC's wiki page.
|
Ergo can emulate certain capabilities of the ZNC bouncer for the benefit of clients, in particular the third-party [playback](https://wiki.znc.in/Playback) module. This enables clients with specific support for ZNC to receive selective history playback automatically. To configure this in [Textual](https://www.codeux.com/textual/), go to "Server properties", select "Vendor specific", uncheck "Do not automatically join channels on connect", and check "Only play back messages you missed". Other clients with support are listed on ZNC's wiki page.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Ergo offers an HTTP API that can be used to control Ergo, or to allow other applications to use Ergo as a source of truth for authentication. The API is documented separately; see [API.md](https://github.com/ergochat/ergo/blob/stable/docs/API.md) on the website, or the `API.md` file that was bundled with your release.
|
||||||
|
|
||||||
## External authentication systems
|
## External authentication systems
|
||||||
|
|
||||||
Ergo can be configured to call arbitrary scripts to authenticate users; see the `auth-script` section of the config. The API for these scripts is as follows: Ergo will invoke the script with a configurable set of arguments, then send it the authentication data as JSON on the first line (`\n`-terminated) of stdin. The input is a JSON dictionary with the following keys:
|
Ergo can be configured to call arbitrary scripts to authenticate users; see the `auth-script` section of the config. The API for these scripts is as follows: Ergo will invoke the script with a configurable set of arguments, then send it the authentication data as JSON on the first line (`\n`-terminated) of stdin. The input is a JSON dictionary with the following keys:
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
49
ergo.go
49
ergo.go
@ -7,6 +7,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@ -14,31 +15,29 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"golang.org/x/crypto/ssh/terminal"
|
"golang.org/x/term"
|
||||||
|
|
||||||
"github.com/docopt/docopt-go"
|
"github.com/docopt/docopt-go"
|
||||||
"github.com/ergochat/ergo/irc"
|
"github.com/ergochat/ergo/irc"
|
||||||
"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 getPassword() string {
|
func getPasswordFromTerminal() string {
|
||||||
fd := int(os.Stdin.Fd())
|
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
if terminal.IsTerminal(fd) {
|
if err != nil {
|
||||||
bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
|
log.Fatal("Error reading password:", err.Error())
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Error reading password:", err.Error())
|
|
||||||
}
|
|
||||||
return string(bytePassword)
|
|
||||||
}
|
}
|
||||||
reader := bufio.NewReader(os.Stdin)
|
return string(bytePassword)
|
||||||
text, _ := reader.ReadString('\n')
|
|
||||||
return strings.TrimSpace(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func fileDoesNotExist(file string) bool {
|
func fileDoesNotExist(file string) bool {
|
||||||
@ -100,6 +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
|
||||||
@ -114,19 +115,20 @@ Options:
|
|||||||
// don't require a config file for genpasswd
|
// don't require a config file for genpasswd
|
||||||
if arguments["genpasswd"].(bool) {
|
if arguments["genpasswd"].(bool) {
|
||||||
var password string
|
var password string
|
||||||
fd := int(os.Stdin.Fd())
|
if term.IsTerminal(int(syscall.Stdin)) {
|
||||||
if terminal.IsTerminal(fd) {
|
|
||||||
fmt.Print("Enter Password: ")
|
fmt.Print("Enter Password: ")
|
||||||
password = getPassword()
|
password = getPasswordFromTerminal()
|
||||||
fmt.Print("\n")
|
fmt.Print("\n")
|
||||||
fmt.Print("Reenter Password: ")
|
fmt.Print("Reenter Password: ")
|
||||||
confirm := getPassword()
|
confirm := getPasswordFromTerminal()
|
||||||
fmt.Print("\n")
|
fmt.Print("\n")
|
||||||
if confirm != password {
|
if confirm != password {
|
||||||
log.Fatal("passwords do not match")
|
log.Fatal("passwords do not match")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
password = getPassword()
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
text, _ := reader.ReadString('\n')
|
||||||
|
password = strings.TrimSpace(text)
|
||||||
}
|
}
|
||||||
if err := irc.ValidatePassphrase(password); err != nil {
|
if err := irc.ValidatePassphrase(password); err != nil {
|
||||||
log.Printf("WARNING: this password contains characters that may cause problems with your IRC client software.\n")
|
log.Printf("WARNING: this password contains characters that may cause problems with your IRC client software.\n")
|
||||||
@ -136,10 +138,13 @@ Options:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("encoding error:", err.Error())
|
log.Fatal("encoding error:", err.Error())
|
||||||
}
|
}
|
||||||
fmt.Print(string(hash))
|
fmt.Println(string(hash))
|
||||||
if terminal.IsTerminal(fd) {
|
return
|
||||||
fmt.Println()
|
} else if arguments["defaultconfig"].(bool) {
|
||||||
}
|
fmt.Print(defaultConfig)
|
||||||
|
return
|
||||||
|
} else if arguments["gentoken"].(bool) {
|
||||||
|
fmt.Println(utils.GenerateSecretKey())
|
||||||
return
|
return
|
||||||
} else if arguments["mkcerts"].(bool) {
|
} else if arguments["mkcerts"].(bool) {
|
||||||
doMkcerts(arguments["--conf"].(string), arguments["--quiet"].(bool))
|
doMkcerts(arguments["--conf"].(string), arguments["--quiet"].(bool))
|
||||||
@ -188,7 +193,7 @@ Options:
|
|||||||
|
|
||||||
// warning if running a non-final version
|
// warning if running a non-final version
|
||||||
if strings.Contains(irc.Ver, "unreleased") {
|
if strings.Contains(irc.Ver, "unreleased") {
|
||||||
logman.Warning("server", "You are currently running an unreleased beta version of Ergo that may be unstable and could corrupt your database.\nIf you are running a production network, please download the latest build from https://ergo.chat/downloads.html and run that instead.")
|
logman.Warning("server", "You are currently running an unreleased beta version of Ergo that may be unstable and could corrupt your database.\nIf you are running a production network, please download the latest build from https://ergo.chat/about and run that instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := irc.NewServer(config, logman)
|
server, err := irc.NewServer(config, logman)
|
||||||
|
|||||||
@ -87,6 +87,12 @@ CAPDEFS = [
|
|||||||
url="https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6",
|
url="https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6",
|
||||||
standard="proposed IRCv3",
|
standard="proposed IRCv3",
|
||||||
),
|
),
|
||||||
|
CapDef(
|
||||||
|
identifier="MessageRedaction",
|
||||||
|
name="draft/message-redaction",
|
||||||
|
url="https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md",
|
||||||
|
standard="proposed IRCv3",
|
||||||
|
),
|
||||||
CapDef(
|
CapDef(
|
||||||
identifier="MessageTags",
|
identifier="MessageTags",
|
||||||
name="message-tags",
|
name="message-tags",
|
||||||
@ -195,6 +201,49 @@ CAPDEFS = [
|
|||||||
url="https://github.com/ircv3/ircv3-specifications/pull/503",
|
url="https://github.com/ircv3/ircv3-specifications/pull/503",
|
||||||
standard="proposed IRCv3",
|
standard="proposed IRCv3",
|
||||||
),
|
),
|
||||||
|
CapDef(
|
||||||
|
identifier="Preaway",
|
||||||
|
name="draft/pre-away",
|
||||||
|
url="https://github.com/ircv3/ircv3-specifications/pull/514",
|
||||||
|
standard="proposed IRCv3",
|
||||||
|
),
|
||||||
|
CapDef(
|
||||||
|
identifier="StandardReplies",
|
||||||
|
name="standard-replies",
|
||||||
|
url="https://github.com/ircv3/ircv3-specifications/pull/506",
|
||||||
|
standard="IRCv3",
|
||||||
|
),
|
||||||
|
CapDef(
|
||||||
|
identifier="NoImplicitNames",
|
||||||
|
name="draft/no-implicit-names",
|
||||||
|
url="https://github.com/ircv3/ircv3-specifications/pull/527",
|
||||||
|
standard="proposed IRCv3",
|
||||||
|
),
|
||||||
|
CapDef(
|
||||||
|
identifier="ExtendedISupport",
|
||||||
|
name="draft/extended-isupport",
|
||||||
|
url="https://github.com/ircv3/ircv3-specifications/pull/543",
|
||||||
|
standard="proposed IRCv3",
|
||||||
|
),
|
||||||
|
CapDef(
|
||||||
|
identifier="WebPush",
|
||||||
|
name="draft/webpush",
|
||||||
|
url="https://github.com/ircv3/ircv3-specifications/pull/471",
|
||||||
|
standard="proposed IRCv3",
|
||||||
|
),
|
||||||
|
CapDef(
|
||||||
|
identifier="SojuWebPush",
|
||||||
|
name="soju.im/webpush",
|
||||||
|
url="https://github.com/ircv3/ircv3-specifications/pull/471",
|
||||||
|
standard="Soju/Goguma vendor",
|
||||||
|
),
|
||||||
|
CapDef(
|
||||||
|
identifier="Metadata",
|
||||||
|
name="draft/metadata-2",
|
||||||
|
url="https://ircv3.net/specs/extensions/metadata",
|
||||||
|
standard="draft IRCv3",
|
||||||
|
),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate_defs():
|
def validate_defs():
|
||||||
@ -230,7 +279,7 @@ package caps
|
|||||||
const (
|
const (
|
||||||
// number of recognized capabilities:
|
// number of recognized capabilities:
|
||||||
numCapabs = %d
|
numCapabs = %d
|
||||||
// length of the uint64 array that represents the bitset:
|
// length of the uint32 array that represents the bitset:
|
||||||
bitsetLen = %d
|
bitsetLen = %d
|
||||||
)
|
)
|
||||||
""" % (numCapabs, bitsetLen), file=output)
|
""" % (numCapabs, bitsetLen), file=output)
|
||||||
|
|||||||
51
go.mod
51
go.mod
@ -1,43 +1,62 @@
|
|||||||
module github.com/ergochat/ergo
|
module github.com/ergochat/ergo
|
||||||
|
|
||||||
go 1.19
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48
|
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48
|
||||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
|
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
|
||||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
|
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
|
||||||
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1
|
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1
|
||||||
github.com/ergochat/go-ident v0.0.0-20200511222032-830550b1d775
|
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881
|
||||||
github.com/ergochat/irc-go v0.1.0
|
github.com/ergochat/irc-go v0.5.0
|
||||||
github.com/go-sql-driver/mysql v1.6.0
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
github.com/go-test/deep v1.0.6 // indirect
|
github.com/gofrs/flock v0.8.1
|
||||||
github.com/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/tidwall/buntdb v1.3.2
|
||||||
github.com/tidwall/buntdb v1.2.9
|
|
||||||
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.0.0-20220525230936-793ad666bf5e
|
golang.org/x/crypto v0.46.0
|
||||||
golang.org/x/text v0.3.7
|
golang.org/x/term v0.38.0
|
||||||
|
golang.org/x/text v0.32.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/gofrs/flock v0.8.1
|
require (
|
||||||
|
github.com/emersion/go-msgauth v0.7.0
|
||||||
|
github.com/ergochat/webpush-go/v2 v2.0.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
|
modernc.org/sqlite v1.42.2
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/tidwall/btree v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.1 // indirect
|
||||||
github.com/tidwall/gjson v1.12.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1 // indirect
|
||||||
|
github.com/tidwall/btree v1.4.2 // indirect
|
||||||
|
github.com/tidwall/gjson v1.14.3 // indirect
|
||||||
github.com/tidwall/grect v0.1.4 // indirect
|
github.com/tidwall/grect v0.1.4 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
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.0.0-20210615035016-665e8c7367d1 // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/gorilla/websocket => github.com/ergochat/websocket v1.4.2-oragono1
|
replace github.com/gorilla/websocket => github.com/ergochat/websocket v1.4.2-oragono1
|
||||||
|
|||||||
155
go.sum
155
go.sum
@ -1,35 +1,64 @@
|
|||||||
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48 h1:/EMHruHCFXR9xClkGV/t0rmHrdhX4+trQUcBqjwc9xE=
|
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48 h1:/EMHruHCFXR9xClkGV/t0rmHrdhX4+trQUcBqjwc9xE=
|
||||||
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc=
|
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc=
|
||||||
|
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||||
|
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
|
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
|
||||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
|
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
|
||||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
|
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
|
||||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
|
||||||
|
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
|
||||||
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 h1:WLHTOodthVyv5NvYLIvWl112kSFv5IInKKrRN2qpons=
|
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 h1:WLHTOodthVyv5NvYLIvWl112kSFv5IInKKrRN2qpons=
|
||||||
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk=
|
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk=
|
||||||
github.com/ergochat/go-ident v0.0.0-20200511222032-830550b1d775 h1:QSJIdpr3HOzJDPwxT7hp7WbjoZcS+5GqVvsBscqChk0=
|
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms=
|
||||||
github.com/ergochat/go-ident v0.0.0-20200511222032-830550b1d775/go.mod h1:d2qvgjD0TvGNSvUs+mZgX090RiJlrzUYW6vtANGOy3A=
|
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM=
|
||||||
github.com/ergochat/irc-go v0.0.0-20210617222258-256f1601d3ce h1:RfyjeynouKZjmnN8WGzCSrtuHGZ9dwfSYBq405FPoqs=
|
github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
|
||||||
github.com/ergochat/irc-go v0.0.0-20210617222258-256f1601d3ce/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
||||||
github.com/ergochat/irc-go v0.1.0 h1:jBHUayERH9SiPOWe4ePDWRztBjIQsU/jwLbbGUuiOWM=
|
|
||||||
github.com/ergochat/irc-go v0.1.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
|
||||||
github.com/ergochat/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.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-test/deep v1.0.6 h1:UHSEyLZUwX9Qoi99vVwvewiMC8mM2bf7XEM2nqvzEn8=
|
|
||||||
github.com/go-test/deep v1.0.6/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
|
||||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd h1:+iAPaTbi1gZpcpDwe/BW1fx7Xoesv69hLNGPheoyhBs=
|
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd h1:+iAPaTbi1gZpcpDwe/BW1fx7Xoesv69hLNGPheoyhBs=
|
||||||
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd/go.mod h1:4soZNh0zW0LtYGdQ416i0jO0EIqMGcbtaspRS4BDvRQ=
|
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd/go.mod h1:4soZNh0zW0LtYGdQ416i0jO0EIqMGcbtaspRS4BDvRQ=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
@ -40,25 +69,24 @@ github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg=
|
|||||||
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
|
github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
|
||||||
github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
|
github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
|
||||||
github.com/tidwall/btree v0.6.1 h1:75VVgBeviiDO+3g4U+7+BaNBNhNINxB0ULPT3fs9pMY=
|
github.com/tidwall/btree v1.4.2 h1:PpkaieETJMUxYNADsjgtNRcERX7mGc/GP2zp/r5FM3g=
|
||||||
github.com/tidwall/btree v0.6.1/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4=
|
github.com/tidwall/btree v1.4.2/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE=
|
||||||
github.com/tidwall/btree v1.1.0 h1:5P+9WU8ui5uhmcg3SoPyTwoI0mVyZ1nps7YQzTZFkYM=
|
github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg=
|
||||||
github.com/tidwall/btree v1.1.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4=
|
github.com/tidwall/buntdb v1.3.2/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU=
|
||||||
github.com/tidwall/buntdb v1.2.7 h1:SIyObKAymzLyGhDeIhVk2Yc1/EwfCC75Uyu77CHlVoA=
|
|
||||||
github.com/tidwall/buntdb v1.2.7/go.mod h1:b6KvZM27x/8JLI5hgRhRu60pa3q0Tz9c50TyD46OHUM=
|
|
||||||
github.com/tidwall/buntdb v1.2.9 h1:XVz684P7X6HCTrdr385yDZWB1zt/n20ZNG3M1iGyFm4=
|
|
||||||
github.com/tidwall/buntdb v1.2.9/go.mod h1:IwyGSvvDg6hnKSIhtdZ0AqhCZGH8ukdtCAzaP8fI1X4=
|
|
||||||
github.com/tidwall/gjson v1.10.2 h1:APbLGOM0rrEkd8WBw9C24nllro4ajFuJu0Sc9hRz8Bo=
|
|
||||||
github.com/tidwall/gjson v1.10.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/gjson v1.12.1 h1:ikuZsLdhr8Ws0IdROXUS1Gi4v9Z4pGqpX/CvJkxvfpo=
|
|
||||||
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/grect v0.1.3 h1:z9YwQAMUxVSBde3b7Sl8Da37rffgNfZ6Fq6h9t6KdXE=
|
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
|
||||||
github.com/tidwall/grect v0.1.3/go.mod h1:8GMjwh3gPZVpLBI/jDz9uslCe0dpxRpWDdtN0lWAS/E=
|
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
|
github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
|
||||||
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
|
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
|
||||||
github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
|
github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
|
||||||
@ -71,43 +99,76 @@ github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8=
|
|||||||
github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ=
|
github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ=
|
||||||
github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE=
|
github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE=
|
||||||
github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw=
|
github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw=
|
||||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
|
|
||||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc=
|
github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc=
|
||||||
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
|
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
|
||||||
golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8 h1:5QRxNnVsaJP6NAse0UdkRgL3zHMvCRRkrDVLNdNpdy4=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
|
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
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.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
|
||||||
|
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
228
irc/accounts.go
228
irc/accounts.go
@ -4,10 +4,12 @@
|
|||||||
package irc
|
package irc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -23,6 +25,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"
|
||||||
)
|
)
|
||||||
@ -39,7 +42,6 @@ const (
|
|||||||
keyAccountSettings = "account.settings %s"
|
keyAccountSettings = "account.settings %s"
|
||||||
keyAccountVHost = "account.vhost %s"
|
keyAccountVHost = "account.vhost %s"
|
||||||
keyCertToAccount = "account.creds.certfp %s"
|
keyCertToAccount = "account.creds.certfp %s"
|
||||||
keyAccountChannels = "account.channels %s" // channels registered to the account
|
|
||||||
keyAccountLastSeen = "account.lastseen %s"
|
keyAccountLastSeen = "account.lastseen %s"
|
||||||
keyAccountReadMarkers = "account.readmarkers %s"
|
keyAccountReadMarkers = "account.readmarkers %s"
|
||||||
keyAccountModes = "account.modes %s" // user modes for the always-on client as a string
|
keyAccountModes = "account.modes %s" // user modes for the always-on client as a string
|
||||||
@ -49,7 +51,9 @@ const (
|
|||||||
keyAccountEmailChange = "account.emailchange %s"
|
keyAccountEmailChange = "account.emailchange %s"
|
||||||
// for an always-on client, a map of channel names they're in to their current modes
|
// for an always-on client, a map of channel names they're in to their current modes
|
||||||
// (not to be confused with their amodes, which a non-always-on client can have):
|
// (not to be confused with their amodes, which a non-always-on client can have):
|
||||||
keyAccountChannelToModes = "account.channeltomodes %s"
|
keyAccountChannelToModes = "account.channeltomodes %s"
|
||||||
|
keyAccountPushSubscriptions = "account.pushsubscriptions %s"
|
||||||
|
keyAccountMetadata = "account.metadata %s"
|
||||||
|
|
||||||
maxCertfpsPerAccount = 5
|
maxCertfpsPerAccount = 5
|
||||||
)
|
)
|
||||||
@ -134,6 +138,8 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) {
|
|||||||
am.loadTimeMap(keyAccountReadMarkers, accountName),
|
am.loadTimeMap(keyAccountReadMarkers, accountName),
|
||||||
am.loadModes(accountName),
|
am.loadModes(accountName),
|
||||||
am.loadRealname(accountName),
|
am.loadRealname(accountName),
|
||||||
|
am.loadPushSubscriptions(accountName),
|
||||||
|
am.loadMetadata(accountName),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -714,6 +720,74 @@ func (am *AccountManager) loadRealname(account string) (realname string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (am *AccountManager) savePushSubscriptions(account string, subs []storedPushSubscription) {
|
||||||
|
j, err := json.Marshal(subs)
|
||||||
|
if err != nil {
|
||||||
|
am.server.logger.Error("internal", "error storing push subscriptions", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val := string(j)
|
||||||
|
key := fmt.Sprintf(keyAccountPushSubscriptions, account)
|
||||||
|
am.server.store.Update(func(tx *buntdb.Tx) error {
|
||||||
|
tx.Set(key, val, nil)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AccountManager) loadPushSubscriptions(account string) (result []storedPushSubscription) {
|
||||||
|
key := fmt.Sprintf(keyAccountPushSubscriptions, account)
|
||||||
|
var val string
|
||||||
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
||||||
|
val, _ = tx.Get(key)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if val == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(val), &result); err == nil {
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
am.server.logger.Error("internal", "error loading push subscriptions", err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AccountManager) saveMetadata(account string, metadata map[string]string) {
|
||||||
|
j, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
am.server.logger.Error("internal", "error storing metadata", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val := string(j)
|
||||||
|
key := fmt.Sprintf(keyAccountMetadata, account)
|
||||||
|
am.server.store.Update(func(tx *buntdb.Tx) error {
|
||||||
|
tx.Set(key, val, nil)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AccountManager) loadMetadata(account string) (result map[string]string) {
|
||||||
|
key := fmt.Sprintf(keyAccountMetadata, account)
|
||||||
|
var val string
|
||||||
|
am.server.store.View(func(tx *buntdb.Tx) error {
|
||||||
|
val, _ = tx.Get(key)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if val == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(val), &result); err == nil {
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
am.server.logger.Error("internal", "error loading metadata", err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) {
|
func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) {
|
||||||
certfp, err = utils.NormalizeCertfp(certfp)
|
certfp, err = utils.NormalizeCertfp(certfp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -949,7 +1023,7 @@ func (am *AccountManager) Verify(client *Client, account string, code string, ad
|
|||||||
if client != nil {
|
if client != nil {
|
||||||
am.Login(client, clientAccount)
|
am.Login(client, clientAccount)
|
||||||
if client.AlwaysOn() {
|
if client.AlwaysOn() {
|
||||||
client.markDirty(IncludeRealname)
|
client.markDirty(IncludeAllAttrs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// we may need to do nick enforcement here:
|
// we may need to do nick enforcement here:
|
||||||
@ -1120,7 +1194,7 @@ func (am *AccountManager) NsSendpass(client *Client, accountName string) (err er
|
|||||||
message := email.ComposeMail(config.Accounts.Registration.EmailVerification, account.Settings.Email, subject)
|
message := email.ComposeMail(config.Accounts.Registration.EmailVerification, account.Settings.Email, subject)
|
||||||
fmt.Fprintf(&message, client.t("We received a request to reset your password on %[1]s for account: %[2]s"), am.server.name, account.Name)
|
fmt.Fprintf(&message, client.t("We received a request to reset your password on %[1]s for account: %[2]s"), am.server.name, account.Name)
|
||||||
message.WriteString("\r\n")
|
message.WriteString("\r\n")
|
||||||
fmt.Fprintf(&message, client.t("If you did not initiate this request, you can safely ignore this message."))
|
message.WriteString(client.t("If you did not initiate this request, you can safely ignore this message."))
|
||||||
message.WriteString("\r\n")
|
message.WriteString("\r\n")
|
||||||
message.WriteString("\r\n")
|
message.WriteString("\r\n")
|
||||||
message.WriteString(client.t("Otherwise, to reset your password, issue the following command (replace `new_password` with your desired password):"))
|
message.WriteString(client.t("Otherwise, to reset your password, issue the following command (replace `new_password` with your desired password):"))
|
||||||
@ -1428,6 +1502,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, "")
|
||||||
@ -1765,7 +1907,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
|
|||||||
nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
|
nicksKey := fmt.Sprintf(keyAccountAdditionalNicks, casefoldedAccount)
|
||||||
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
|
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
|
||||||
vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount)
|
vhostKey := fmt.Sprintf(keyAccountVHost, casefoldedAccount)
|
||||||
channelsKey := fmt.Sprintf(keyAccountChannels, casefoldedAccount)
|
|
||||||
joinedChannelsKey := fmt.Sprintf(keyAccountChannelToModes, casefoldedAccount)
|
joinedChannelsKey := fmt.Sprintf(keyAccountChannelToModes, casefoldedAccount)
|
||||||
lastSeenKey := fmt.Sprintf(keyAccountLastSeen, casefoldedAccount)
|
lastSeenKey := fmt.Sprintf(keyAccountLastSeen, casefoldedAccount)
|
||||||
readMarkersKey := fmt.Sprintf(keyAccountReadMarkers, casefoldedAccount)
|
readMarkersKey := fmt.Sprintf(keyAccountReadMarkers, casefoldedAccount)
|
||||||
@ -1775,16 +1916,17 @@ 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() {
|
||||||
am.killClients(clients)
|
am.killClients(clients)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var registeredChannels []string
|
|
||||||
// on our way out, unregister all the account's channels and delete them from the db
|
// on our way out, unregister all the account's channels and delete them from the db
|
||||||
defer func() {
|
defer func() {
|
||||||
for _, channelName := range registeredChannels {
|
for _, channelName := range am.server.channels.ChannelsForAccount(casefoldedAccount) {
|
||||||
err := am.server.channels.SetUnregistered(channelName, casefoldedAccount)
|
err := am.server.channels.SetUnregistered(channelName, casefoldedAccount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
am.server.logger.Error("internal", "couldn't unregister channel", channelName, err.Error())
|
am.server.logger.Error("internal", "couldn't unregister channel", channelName, err.Error())
|
||||||
@ -1799,7 +1941,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
|
|||||||
defer am.serialCacheUpdateMutex.Unlock()
|
defer am.serialCacheUpdateMutex.Unlock()
|
||||||
|
|
||||||
var accountName string
|
var accountName string
|
||||||
var channelsStr string
|
|
||||||
keepProtections := false
|
keepProtections := false
|
||||||
am.server.store.Update(func(tx *buntdb.Tx) error {
|
am.server.store.Update(func(tx *buntdb.Tx) error {
|
||||||
// get the unfolded account name; for an active account, this is
|
// get the unfolded account name; for an active account, this is
|
||||||
@ -1827,8 +1968,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
|
|||||||
credText, err = tx.Get(credentialsKey)
|
credText, err = tx.Get(credentialsKey)
|
||||||
tx.Delete(credentialsKey)
|
tx.Delete(credentialsKey)
|
||||||
tx.Delete(vhostKey)
|
tx.Delete(vhostKey)
|
||||||
channelsStr, _ = tx.Get(channelsKey)
|
|
||||||
tx.Delete(channelsKey)
|
|
||||||
tx.Delete(joinedChannelsKey)
|
tx.Delete(joinedChannelsKey)
|
||||||
tx.Delete(lastSeenKey)
|
tx.Delete(lastSeenKey)
|
||||||
tx.Delete(readMarkersKey)
|
tx.Delete(readMarkersKey)
|
||||||
@ -1837,6 +1976,8 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
|
|||||||
tx.Delete(suspendedKey)
|
tx.Delete(suspendedKey)
|
||||||
tx.Delete(pwResetKey)
|
tx.Delete(pwResetKey)
|
||||||
tx.Delete(emailChangeKey)
|
tx.Delete(emailChangeKey)
|
||||||
|
tx.Delete(pushSubscriptionsKey)
|
||||||
|
tx.Delete(metadataKey)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@ -1858,7 +1999,6 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
|
|||||||
|
|
||||||
skeleton, _ := Skeleton(accountName)
|
skeleton, _ := Skeleton(accountName)
|
||||||
additionalNicks := unmarshalReservedNicks(rawNicks)
|
additionalNicks := unmarshalReservedNicks(rawNicks)
|
||||||
registeredChannels = unmarshalRegisteredChannels(channelsStr)
|
|
||||||
|
|
||||||
am.Lock()
|
am.Lock()
|
||||||
defer am.Unlock()
|
defer am.Unlock()
|
||||||
@ -1890,28 +2030,26 @@ func unmarshalRegisteredChannels(channelsStr string) (result []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AccountManager) ChannelsForAccount(account string) (channels []string) {
|
|
||||||
cfaccount, err := CasefoldName(account)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var channelStr string
|
|
||||||
key := fmt.Sprintf(keyAccountChannels, cfaccount)
|
|
||||||
am.server.store.View(func(tx *buntdb.Tx) error {
|
|
||||||
channelStr, _ = tx.Get(key)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return unmarshalRegisteredChannels(channelStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp string, peerCerts []*x509.Certificate, authzid string) (err error) {
|
func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp string, peerCerts []*x509.Certificate, authzid string) (err error) {
|
||||||
if certfp == "" {
|
if certfp == "" {
|
||||||
return errAccountInvalidCredentials
|
return errAccountInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientAccount ClientAccount
|
clientAccount, err := am.checkCertAuth(client.IP(), certfp, peerCerts, authzid)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if client.registered {
|
||||||
|
if clientAlready := am.server.clients.Get(clientAccount.Name); clientAlready != nil && clientAlready.AlwaysOn() {
|
||||||
|
err = errNickAccountMismatch
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
am.Login(client, clientAccount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AccountManager) checkCertAuth(ip net.IP, certfp string, peerCerts []*x509.Certificate, authzid string) (clientAccount ClientAccount, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@ -1922,22 +2060,19 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin
|
|||||||
err = errAccountSuspended
|
err = errAccountSuspended
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO(#1109) clean this check up?
|
|
||||||
if client.registered {
|
|
||||||
if clientAlready := am.server.clients.Get(clientAccount.Name); clientAlready != nil && clientAlready.AlwaysOn() {
|
|
||||||
err = errNickAccountMismatch
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
am.Login(client, clientAccount)
|
|
||||||
return
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
config := am.server.Config()
|
config := am.server.Config()
|
||||||
if config.Accounts.AuthScript.Enabled {
|
if config.Accounts.AuthScript.Enabled {
|
||||||
var output AuthScriptOutput
|
var output AuthScriptOutput
|
||||||
output, err = CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
|
var ipString string
|
||||||
AuthScriptInput{Certfp: certfp, IP: client.IP().String(), peerCerts: peerCerts})
|
if ip != nil {
|
||||||
|
ipString = ip.String()
|
||||||
|
}
|
||||||
|
output, err = CheckAuthScript(
|
||||||
|
am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
|
||||||
|
AuthScriptInput{Certfp: certfp, IP: ipString, peerCerts: peerCerts},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
|
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
|
||||||
} else if output.Success && output.AccountName != "" {
|
} else if output.Success && output.AccountName != "" {
|
||||||
@ -1958,16 +2093,19 @@ func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp strin
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if authzid != "" && authzid != account {
|
if authzid != "" {
|
||||||
return errAuthzidAuthcidMismatch
|
if cfAuthzid, cErr := CasefoldName(authzid); cErr != nil || cfAuthzid != account {
|
||||||
|
err = errAuthzidAuthcidMismatch
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ok, we found an account corresponding to their certificate
|
// ok, we found an account corresponding to their certificate
|
||||||
clientAccount, err = am.LoadAccount(account)
|
clientAccount, err = am.LoadAccount(account)
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type settingsMunger func(input AccountSettings) (output AccountSettings, err error)
|
type settingsMunger func(input AccountSettings) (output AccountSettings, err error)
|
||||||
@ -2167,6 +2305,8 @@ var (
|
|||||||
"PLAIN": authPlainHandler,
|
"PLAIN": authPlainHandler,
|
||||||
"EXTERNAL": authExternalHandler,
|
"EXTERNAL": authExternalHandler,
|
||||||
"SCRAM-SHA-256": authScramHandler,
|
"SCRAM-SHA-256": authScramHandler,
|
||||||
|
"OAUTHBEARER": authOauthBearerHandler,
|
||||||
|
"IRCV3BEARER": authIRCv3BearerHandler,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2209,7 +2349,7 @@ func (ac *AccountCredentials) Serialize() (result string, err error) {
|
|||||||
return string(credText), nil
|
return string(credText), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint) (err error) {
|
func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost int) (err error) {
|
||||||
if passphrase == "" {
|
if passphrase == "" {
|
||||||
ac.PassphraseHash = nil
|
ac.PassphraseHash = nil
|
||||||
ac.SCRAMCreds = SCRAMCreds{}
|
ac.SCRAMCreds = SCRAMCreds{}
|
||||||
@ -2220,7 +2360,7 @@ func (ac *AccountCredentials) SetPassphrase(passphrase string, bcryptCost uint)
|
|||||||
return errAccountBadPassphrase
|
return errAccountBadPassphrase
|
||||||
}
|
}
|
||||||
|
|
||||||
ac.PassphraseHash, err = passwd.GenerateFromPassword([]byte(passphrase), int(bcryptCost))
|
ac.PassphraseHash, err = passwd.GenerateFromPassword([]byte(passphrase), bcryptCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errAccountBadPassphrase
|
return errAccountBadPassphrase
|
||||||
}
|
}
|
||||||
|
|||||||
435
irc/api.go
Normal file
435
irc/api.go
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/modes"
|
||||||
|
"github.com/ergochat/ergo/irc/sno"
|
||||||
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAPIHandler(server *Server) http.Handler {
|
||||||
|
api := &ergoAPI{
|
||||||
|
server: server,
|
||||||
|
mux: http.NewServeMux(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// server-level functionality:
|
||||||
|
api.mux.HandleFunc("POST /v1/rehash", api.handleRehash)
|
||||||
|
api.mux.HandleFunc("POST /v1/status", api.handleStatus)
|
||||||
|
api.mux.HandleFunc("POST /v1/list", api.handleList)
|
||||||
|
api.mux.HandleFunc("POST /v1/defcon", api.handleDefcon)
|
||||||
|
|
||||||
|
// use Ergo as a source of truth for authentication in other services:
|
||||||
|
api.mux.HandleFunc("POST /v1/check_auth", api.handleCheckAuth)
|
||||||
|
|
||||||
|
// legacy names for /v1/ns endpoints:
|
||||||
|
api.mux.HandleFunc("POST /v1/saregister", api.handleSaregister)
|
||||||
|
api.mux.HandleFunc("POST /v1/account_details", api.handleAccountDetails)
|
||||||
|
api.mux.HandleFunc("POST /v1/account_list", api.handleAccountList)
|
||||||
|
|
||||||
|
// /v1/ns: nickserv functionality
|
||||||
|
api.mux.HandleFunc("POST /v1/ns/info", api.handleAccountDetails)
|
||||||
|
api.mux.HandleFunc("POST /v1/ns/list", api.handleAccountList)
|
||||||
|
api.mux.HandleFunc("POST /v1/ns/passwd", api.handleNsPasswd)
|
||||||
|
api.mux.HandleFunc("POST /v1/ns/saregister", api.handleSaregister)
|
||||||
|
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
type ergoAPI struct {
|
||||||
|
server *Server
|
||||||
|
mux *http.ServeMux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer a.server.HandlePanic(nil)
|
||||||
|
defer a.server.logger.Debug("api", r.URL.Path)
|
||||||
|
|
||||||
|
if a.checkBearerAuth(r.Header.Get("Authorization")) {
|
||||||
|
a.mux.ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) checkBearerAuth(authHeader string) (authorized bool) {
|
||||||
|
if authHeader == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c := a.server.Config()
|
||||||
|
if !c.API.Enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
spaceIdx := strings.IndexByte(authHeader, ' ')
|
||||||
|
if spaceIdx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.EqualFold("Bearer", authHeader[:spaceIdx]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
providedTokenBytes := []byte(authHeader[spaceIdx+1:])
|
||||||
|
for _, tokenBytes := range c.API.bearerTokenBytes {
|
||||||
|
if subtle.ConstantTimeCompare(tokenBytes, providedTokenBytes) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) decodeJSONRequest(request any, w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
|
err = json.NewDecoder(r.Body).Decode(request)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to deserialize json request: %v", err), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) writeJSONResponse(response any, w http.ResponseWriter, r *http.Request) {
|
||||||
|
j, err := json.Marshal(response)
|
||||||
|
if err == nil {
|
||||||
|
j = append(j, '\n') // less annoying in curl output
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(j)
|
||||||
|
} else {
|
||||||
|
a.server.logger.Error("internal", "failed to serialize API response", r.URL.Path, err.Error())
|
||||||
|
http.Error(w, fmt.Sprintf("failed to serialize json response: %v", err), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiGenericResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorCode string `json:"errorCode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleRehash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var response apiGenericResponse
|
||||||
|
err := a.server.rehash()
|
||||||
|
if err == nil {
|
||||||
|
response.Success = true
|
||||||
|
} else {
|
||||||
|
response.Success = false
|
||||||
|
response.Error = err.Error()
|
||||||
|
}
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type defconRequestResponse struct {
|
||||||
|
apiGenericResponse
|
||||||
|
Defcon int `json:"defcon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleDefcon(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var changeRequested uint32
|
||||||
|
var request defconRequestResponse
|
||||||
|
// ignore errors or invalid values
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err == nil {
|
||||||
|
if 1 <= request.Defcon && request.Defcon <= 5 {
|
||||||
|
changeRequested = uint32(request.Defcon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changeRequested != 0 {
|
||||||
|
a.server.SetDefcon(changeRequested)
|
||||||
|
message := fmt.Sprintf("API set DEFCON level to %d", changeRequested)
|
||||||
|
a.server.logger.Info("server", message)
|
||||||
|
a.server.snomasks.Send(sno.LocalAnnouncements, message)
|
||||||
|
}
|
||||||
|
a.writeJSONResponse(
|
||||||
|
defconRequestResponse{
|
||||||
|
apiGenericResponse: apiGenericResponse{Success: true},
|
||||||
|
Defcon: int(a.server.Defcon()),
|
||||||
|
}, w, r,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiCheckAuthResponse struct {
|
||||||
|
apiGenericResponse
|
||||||
|
AccountName string `json:"accountName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleCheckAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request AuthScriptInput
|
||||||
|
if err := a.decodeJSONRequest(&request, w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response apiCheckAuthResponse
|
||||||
|
|
||||||
|
var account ClientAccount
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// try whatever credentials are present
|
||||||
|
if request.AccountName != "" && request.Passphrase != "" {
|
||||||
|
account, err = a.server.accounts.checkPassphrase(request.AccountName, request.Passphrase)
|
||||||
|
} else if request.Certfp != "" {
|
||||||
|
account, err = a.server.accounts.checkCertAuth(nil, request.Certfp, nil, "")
|
||||||
|
} else {
|
||||||
|
err = errAccountInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
// success, no error
|
||||||
|
response.Success = true
|
||||||
|
response.AccountName = account.Name
|
||||||
|
case errAccountDoesNotExist, errAccountInvalidCredentials, errAccountUnverified, errAccountSuspended:
|
||||||
|
// fail, no error
|
||||||
|
response.Success = false
|
||||||
|
default:
|
||||||
|
response.Success = false
|
||||||
|
response.Error = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiSaregisterRequest struct {
|
||||||
|
AccountName string `json:"accountName"`
|
||||||
|
Passphrase string `json:"passphrase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleSaregister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request apiSaregisterRequest
|
||||||
|
if err := a.decodeJSONRequest(&request, w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response apiGenericResponse
|
||||||
|
err := a.server.accounts.SARegister(request.AccountName, request.Passphrase)
|
||||||
|
if err == nil {
|
||||||
|
response.Success = true
|
||||||
|
} else {
|
||||||
|
response.Success = false
|
||||||
|
response.Error = err.Error()
|
||||||
|
switch err {
|
||||||
|
case errAccountAlreadyRegistered, errAccountAlreadyVerified, errNameReserved:
|
||||||
|
response.ErrorCode = "ACCOUNT_EXISTS"
|
||||||
|
case errAccountBadPassphrase:
|
||||||
|
response.ErrorCode = "INVALID_PASSPHRASE"
|
||||||
|
default:
|
||||||
|
response.ErrorCode = "UNKNOWN_ERROR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleNsPasswd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request apiSaregisterRequest
|
||||||
|
if err := a.decodeJSONRequest(&request, w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response apiGenericResponse
|
||||||
|
err := a.server.accounts.setPassword(request.AccountName, request.Passphrase, true)
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
response.Success = true
|
||||||
|
case errAccountDoesNotExist:
|
||||||
|
response.ErrorCode = "ACCOUNT_DOES_NOT_EXIST"
|
||||||
|
case errAccountBadPassphrase, errEmptyCredentials:
|
||||||
|
response.ErrorCode = "INVALID_PASSPHRASE"
|
||||||
|
case errCredsExternallyManaged:
|
||||||
|
response.ErrorCode = "CREDENTIALS_EXTERNALLY_MANAGED"
|
||||||
|
default:
|
||||||
|
a.server.logger.Error("api", "could not change user password:", err.Error())
|
||||||
|
response.ErrorCode = "UNKNOWN_ERROR"
|
||||||
|
}
|
||||||
|
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountDetailsResponse struct {
|
||||||
|
apiGenericResponse
|
||||||
|
AccountName string `json:"accountName,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
RegisteredAt string `json:"registeredAt,omitempty"`
|
||||||
|
Channels []string `json:"channels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountDetailsRequest struct {
|
||||||
|
AccountName string `json:"accountName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleAccountDetails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request apiAccountDetailsRequest
|
||||||
|
if err := a.decodeJSONRequest(&request, w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response apiAccountDetailsResponse
|
||||||
|
|
||||||
|
if request.AccountName != "" {
|
||||||
|
accountData, err := a.server.accounts.LoadAccount(request.AccountName)
|
||||||
|
if err == nil {
|
||||||
|
if !accountData.Verified {
|
||||||
|
err = errAccountUnverified
|
||||||
|
} else if accountData.Suspended != nil {
|
||||||
|
err = errAccountSuspended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
response.AccountName = accountData.Name
|
||||||
|
response.Email = accountData.Settings.Email
|
||||||
|
if !accountData.RegisteredAt.IsZero() {
|
||||||
|
response.RegisteredAt = accountData.RegisteredAt.Format(utils.IRCv3TimestampFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channels the account is in
|
||||||
|
response.Channels = a.server.channels.ChannelsForAccount(accountData.NameCasefolded)
|
||||||
|
response.Success = true
|
||||||
|
case errAccountDoesNotExist, errAccountUnverified, errAccountSuspended:
|
||||||
|
response.Success = false
|
||||||
|
default:
|
||||||
|
response.Success = false
|
||||||
|
response.ErrorCode = "UNKNOWN_ERROR"
|
||||||
|
response.Error = err.Error()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
response.Success = false
|
||||||
|
response.ErrorCode = "INVALID_REQUEST"
|
||||||
|
}
|
||||||
|
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountListResponse struct {
|
||||||
|
apiGenericResponse
|
||||||
|
Accounts []apiAccountDetailsResponse `json:"accounts"`
|
||||||
|
TotalCount int `json:"totalCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleAccountList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var response apiAccountListResponse
|
||||||
|
|
||||||
|
// Get all account names
|
||||||
|
accounts := a.server.accounts.AllNicks()
|
||||||
|
response.TotalCount = len(accounts)
|
||||||
|
|
||||||
|
// Load account details
|
||||||
|
response.Accounts = make([]apiAccountDetailsResponse, 0, len(accounts))
|
||||||
|
for _, account := range accounts {
|
||||||
|
accountData, err := a.server.accounts.LoadAccount(account)
|
||||||
|
if err != nil {
|
||||||
|
// shouldn't happen
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Accounts = append(
|
||||||
|
response.Accounts,
|
||||||
|
apiAccountDetailsResponse{
|
||||||
|
apiGenericResponse: apiGenericResponse{
|
||||||
|
Success: true,
|
||||||
|
},
|
||||||
|
AccountName: accountData.Name,
|
||||||
|
Email: accountData.Settings.Email,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success = true
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiStatusResponse struct {
|
||||||
|
apiGenericResponse
|
||||||
|
Version string `json:"version"`
|
||||||
|
GoVersion string `json:"go_version"`
|
||||||
|
Commit string `json:"commit,omitempty"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
Users struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Invisible int `json:"invisible"`
|
||||||
|
Operators int `json:"operators"`
|
||||||
|
Unknown int `json:"unknown"`
|
||||||
|
Max int `json:"max"`
|
||||||
|
} `json:"users"`
|
||||||
|
Channels int `json:"channels"`
|
||||||
|
Servers int `json:"servers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
server := a.server
|
||||||
|
stats := server.stats.GetValues()
|
||||||
|
|
||||||
|
response := apiStatusResponse{
|
||||||
|
apiGenericResponse: apiGenericResponse{Success: true},
|
||||||
|
Version: SemVer,
|
||||||
|
GoVersion: runtime.Version(),
|
||||||
|
Commit: Commit,
|
||||||
|
StartTime: server.ctime.Format(utils.IRCv3TimestampFormat),
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Users.Total = stats.Total
|
||||||
|
response.Users.Invisible = stats.Invisible
|
||||||
|
response.Users.Operators = stats.Operators
|
||||||
|
response.Users.Unknown = stats.Unknown
|
||||||
|
response.Users.Max = stats.Max
|
||||||
|
response.Channels = server.channels.Len()
|
||||||
|
response.Servers = 1
|
||||||
|
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiChannelData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
HasKey bool `json:"hasKey"`
|
||||||
|
InviteOnly bool `json:"inviteOnly"`
|
||||||
|
Secret bool `json:"secret"`
|
||||||
|
UserCount int `json:"userCount"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
TopicSetAt string `json:"topicSetAt,omitempty"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
Registered bool `json:"registered"`
|
||||||
|
Owner string `json:"owner,omitempty"`
|
||||||
|
RegisteredAt string `json:"registeredAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channel *Channel) apiData() (result apiChannelData) {
|
||||||
|
channel.stateMutex.RLock()
|
||||||
|
defer channel.stateMutex.RUnlock()
|
||||||
|
result.Name = channel.name
|
||||||
|
result.HasKey = channel.key != ""
|
||||||
|
result.InviteOnly = channel.flags.HasMode(modes.InviteOnly)
|
||||||
|
result.Secret = channel.flags.HasMode(modes.Secret)
|
||||||
|
result.UserCount = len(channel.members)
|
||||||
|
result.Topic = channel.topic
|
||||||
|
if !channel.topicSetTime.IsZero() {
|
||||||
|
result.TopicSetAt = channel.topicSetTime.UTC().Format(utils.IRCv3TimestampFormat)
|
||||||
|
}
|
||||||
|
result.CreatedAt = channel.createdTime.UTC().Format(utils.IRCv3TimestampFormat)
|
||||||
|
result.Registered = channel.registeredFounder != ""
|
||||||
|
if result.Registered {
|
||||||
|
result.Owner = channel.registeredFounder
|
||||||
|
if !channel.registeredTime.IsZero() {
|
||||||
|
result.RegisteredAt = channel.registeredTime.UTC().Format(utils.IRCv3TimestampFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiListResponse struct {
|
||||||
|
apiGenericResponse
|
||||||
|
Channels []apiChannelData `json:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ergoAPI) handleList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channels := a.server.channels.ListableChannels()
|
||||||
|
response := apiListResponse{
|
||||||
|
apiGenericResponse: apiGenericResponse{Success: true},
|
||||||
|
Channels: make([]apiChannelData, 0, len(channels)),
|
||||||
|
}
|
||||||
|
for _, channel := range channels {
|
||||||
|
response.Channels = append(response.Channels, channel.apiData())
|
||||||
|
}
|
||||||
|
a.writeJSONResponse(response, w, r)
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/oauth2"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -20,7 +21,8 @@ type AuthScriptInput struct {
|
|||||||
Certfp string `json:"certfp,omitempty"`
|
Certfp string `json:"certfp,omitempty"`
|
||||||
PeerCerts []string `json:"peerCerts,omitempty"`
|
PeerCerts []string `json:"peerCerts,omitempty"`
|
||||||
peerCerts []*x509.Certificate
|
peerCerts []*x509.Certificate
|
||||||
IP string `json:"ip,omitempty"`
|
IP string `json:"ip,omitempty"`
|
||||||
|
OAuthBearer *oauth2.OAuthBearerOptions `json:"oauth2,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthScriptOutput struct {
|
type AuthScriptOutput struct {
|
||||||
|
|||||||
107
irc/bunt/bunt_datastore.go
Normal file
107
irc/bunt/bunt_datastore.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
// Copyright (c) 2022 Shivaram Lingamneni
|
||||||
|
// released under the MIT license
|
||||||
|
|
||||||
|
package bunt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tidwall/buntdb"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/datastore"
|
||||||
|
"github.com/ergochat/ergo/irc/logger"
|
||||||
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuntKey yields a string key corresponding to a (table, UUID) pair.
|
||||||
|
// Ideally this would not be public, but some of the migration code
|
||||||
|
// needs it.
|
||||||
|
func BuntKey(table datastore.Table, uuid utils.UUID) string {
|
||||||
|
return fmt.Sprintf("%x %s", table, uuid.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buntdbDatastore implements datastore.Datastore using a buntdb.
|
||||||
|
type buntdbDatastore struct {
|
||||||
|
db *buntdb.DB
|
||||||
|
logger *logger.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBuntdbDatastore returns a datastore.Datastore backed by buntdb.
|
||||||
|
func NewBuntdbDatastore(db *buntdb.DB, logger *logger.Manager) datastore.Datastore {
|
||||||
|
return &buntdbDatastore{
|
||||||
|
db: db,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buntdbDatastore) Backoff() time.Duration {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buntdbDatastore) GetAll(table datastore.Table) (result []datastore.KV, err error) {
|
||||||
|
tablePrefix := fmt.Sprintf("%x ", table)
|
||||||
|
err = b.db.View(func(tx *buntdb.Tx) error {
|
||||||
|
err := tx.AscendGreaterOrEqual("", tablePrefix, func(key, value string) bool {
|
||||||
|
encUUID, ok := strings.CutPrefix(key, tablePrefix)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
uuid, err := utils.DecodeUUID(encUUID)
|
||||||
|
if err == nil {
|
||||||
|
result = append(result, datastore.KV{UUID: uuid, Value: []byte(value)})
|
||||||
|
} else {
|
||||||
|
b.logger.Error("datastore", "invalid uuid", key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buntdbDatastore) Get(table datastore.Table, uuid utils.UUID) (value []byte, err error) {
|
||||||
|
buntKey := BuntKey(table, uuid)
|
||||||
|
var result string
|
||||||
|
err = b.db.View(func(tx *buntdb.Tx) error {
|
||||||
|
result, err = tx.Get(buntKey)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return []byte(result), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buntdbDatastore) Set(table datastore.Table, uuid utils.UUID, value []byte, expiration time.Time) (err error) {
|
||||||
|
buntKey := BuntKey(table, uuid)
|
||||||
|
var setOptions *buntdb.SetOptions
|
||||||
|
if !expiration.IsZero() {
|
||||||
|
ttl := time.Until(expiration)
|
||||||
|
if ttl > 0 {
|
||||||
|
setOptions = &buntdb.SetOptions{Expires: true, TTL: ttl}
|
||||||
|
} else {
|
||||||
|
return nil // it already expired, i guess?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
strVal := string(value)
|
||||||
|
|
||||||
|
err = b.db.Update(func(tx *buntdb.Tx) error {
|
||||||
|
_, _, err := tx.Set(buntKey, strVal, setOptions)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buntdbDatastore) Delete(table datastore.Table, key utils.UUID) (err error) {
|
||||||
|
buntKey := BuntKey(table, key)
|
||||||
|
err = b.db.Update(func(tx *buntdb.Tx) error {
|
||||||
|
_, err := tx.Delete(buntKey)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
// deleting a nonexistent key is not considered an error
|
||||||
|
switch err {
|
||||||
|
case buntdb.ErrNotFound:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -62,10 +62,13 @@ const (
|
|||||||
RelaymsgTagName = "draft/relaymsg"
|
RelaymsgTagName = "draft/relaymsg"
|
||||||
// BOT mode: https://ircv3.net/specs/extensions/bot-mode
|
// BOT mode: https://ircv3.net/specs/extensions/bot-mode
|
||||||
BotTagName = "bot"
|
BotTagName = "bot"
|
||||||
|
// https://ircv3.net/specs/extensions/chathistory
|
||||||
|
ChathistoryTargetsBatchType = "draft/chathistory-targets"
|
||||||
|
ExtendedISupportBatchType = "draft/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,9 +7,9 @@ package caps
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// number of recognized capabilities:
|
// number of recognized capabilities:
|
||||||
numCapabs = 30
|
numCapabs = 38
|
||||||
// length of the uint64 array that represents the bitset:
|
// length of the uint32 array that represents the bitset:
|
||||||
bitsetLen = 1
|
bitsetLen = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -53,18 +53,38 @@ const (
|
|||||||
// https://github.com/ircv3/ircv3-specifications/pull/362
|
// https://github.com/ircv3/ircv3-specifications/pull/362
|
||||||
EventPlayback Capability = iota
|
EventPlayback Capability = iota
|
||||||
|
|
||||||
|
// ExtendedISupport is the proposed IRCv3 capability named "draft/extended-isupport":
|
||||||
|
// https://github.com/ircv3/ircv3-specifications/pull/543
|
||||||
|
ExtendedISupport Capability = iota
|
||||||
|
|
||||||
// Languages is the proposed IRCv3 capability named "draft/languages":
|
// Languages is the proposed IRCv3 capability named "draft/languages":
|
||||||
// https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6
|
// https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6
|
||||||
Languages Capability = iota
|
Languages Capability = iota
|
||||||
|
|
||||||
|
// MessageRedaction is the proposed IRCv3 capability named "draft/message-redaction":
|
||||||
|
// https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md
|
||||||
|
MessageRedaction Capability = iota
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// NoImplicitNames is the proposed IRCv3 capability named "draft/no-implicit-names":
|
||||||
|
// https://github.com/ircv3/ircv3-specifications/pull/527
|
||||||
|
NoImplicitNames Capability = iota
|
||||||
|
|
||||||
// Persistence is the proposed IRCv3 capability named "draft/persistence":
|
// Persistence is the proposed IRCv3 capability named "draft/persistence":
|
||||||
// https://github.com/ircv3/ircv3-specifications/pull/503
|
// https://github.com/ircv3/ircv3-specifications/pull/503
|
||||||
Persistence Capability = iota
|
Persistence Capability = iota
|
||||||
|
|
||||||
|
// Preaway is the proposed IRCv3 capability named "draft/pre-away":
|
||||||
|
// https://github.com/ircv3/ircv3-specifications/pull/514
|
||||||
|
Preaway Capability = iota
|
||||||
|
|
||||||
// ReadMarker is the draft IRCv3 capability named "draft/read-marker":
|
// ReadMarker is the draft IRCv3 capability named "draft/read-marker":
|
||||||
// https://github.com/ircv3/ircv3-specifications/pull/489
|
// https://github.com/ircv3/ircv3-specifications/pull/489
|
||||||
ReadMarker Capability = iota
|
ReadMarker Capability = iota
|
||||||
@ -73,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
|
||||||
@ -117,6 +141,14 @@ 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":
|
||||||
|
// https://github.com/ircv3/ircv3-specifications/pull/506
|
||||||
|
StandardReplies Capability = iota
|
||||||
|
|
||||||
// STS is the IRCv3 capability named "sts":
|
// STS is the IRCv3 capability named "sts":
|
||||||
// https://ircv3.net/specs/extensions/sts.html
|
// https://ircv3.net/specs/extensions/sts.html
|
||||||
STS Capability = iota
|
STS Capability = iota
|
||||||
@ -147,11 +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/metadata-2",
|
||||||
"draft/multiline",
|
"draft/multiline",
|
||||||
|
"draft/no-implicit-names",
|
||||||
"draft/persistence",
|
"draft/persistence",
|
||||||
|
"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",
|
||||||
@ -163,6 +201,8 @@ var (
|
|||||||
"sasl",
|
"sasl",
|
||||||
"server-time",
|
"server-time",
|
||||||
"setname",
|
"setname",
|
||||||
|
"soju.im/webpush",
|
||||||
|
"standard-replies",
|
||||||
"sts",
|
"sts",
|
||||||
"userhost-in-names",
|
"userhost-in-names",
|
||||||
"znc.in/playback",
|
"znc.in/playback",
|
||||||
|
|||||||
@ -102,6 +102,13 @@ func (s *Set) Strings(version Version, values Values, maxLen int) (result []stri
|
|||||||
var capab Capability
|
var capab Capability
|
||||||
asSlice := s[:]
|
asSlice := s[:]
|
||||||
for capab = 0; capab < numCapabs; capab++ {
|
for capab = 0; capab < numCapabs; capab++ {
|
||||||
|
// XXX clients that only support CAP LS 301 cannot handle multiline
|
||||||
|
// responses. omit some CAPs in this case, forcing the response to fit on
|
||||||
|
// a single line. this is technically buggy for CAP LIST (as opposed to LS)
|
||||||
|
// but it shouldn't matter
|
||||||
|
if version < Cap302 && !isAllowed301(capab) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// skip any capabilities that are not enabled
|
// skip any capabilities that are not enabled
|
||||||
if !utils.BitsetGet(asSlice, uint(capab)) {
|
if !utils.BitsetGet(asSlice, uint(capab)) {
|
||||||
continue
|
continue
|
||||||
@ -122,3 +129,15 @@ func (s *Set) Strings(version Version, values Values, maxLen int) (result []stri
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// this is a fixed whitelist of caps that are eligible for display in CAP LS 301
|
||||||
|
func isAllowed301(capab Capability) bool {
|
||||||
|
switch capab {
|
||||||
|
case AccountNotify, AccountTag, AwayNotify, Batch, ChgHost, Chathistory, EventPlayback,
|
||||||
|
Relaymsg, EchoMessage, Nope, ExtendedJoin, InviteNotify, LabeledResponse, MessageTags,
|
||||||
|
MultiPrefix, SASL, ServerTime, SetName, STS, UserhostInNames, ZNCSelfMessage, ZNCPlayback:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,8 +3,11 @@
|
|||||||
|
|
||||||
package caps
|
package caps
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
import "reflect"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestSets(t *testing.T) {
|
func TestSets(t *testing.T) {
|
||||||
s1 := NewSet()
|
s1 := NewSet()
|
||||||
@ -60,6 +63,19 @@ func TestSets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertEqual(found, expected interface{}) {
|
||||||
|
if !reflect.DeepEqual(found, expected) {
|
||||||
|
panic(fmt.Sprintf("found %#v, expected %#v", found, expected))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test301WhitelistNotRespectedFor302(t *testing.T) {
|
||||||
|
s1 := NewSet()
|
||||||
|
s1.Enable(AccountTag, EchoMessage, StandardReplies)
|
||||||
|
assertEqual(s1.Strings(Cap301, nil, 0), []string{"account-tag echo-message"})
|
||||||
|
assertEqual(s1.Strings(Cap302, nil, 0), []string{"account-tag echo-message standard-replies"})
|
||||||
|
}
|
||||||
|
|
||||||
func TestSubtract(t *testing.T) {
|
func TestSubtract(t *testing.T) {
|
||||||
s1 := NewSet(AccountTag, EchoMessage, UserhostInNames, ServerTime)
|
s1 := NewSet(AccountTag, EchoMessage, UserhostInNames, ServerTime)
|
||||||
|
|
||||||
|
|||||||
350
irc/channel.go
350
irc/channel.go
@ -7,18 +7,22 @@ package irc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
|
"maps"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ergochat/irc-go/ircutils"
|
"github.com/ergochat/irc-go/ircmsg"
|
||||||
|
|
||||||
"github.com/ergochat/ergo/irc/caps"
|
"github.com/ergochat/ergo/irc/caps"
|
||||||
|
"github.com/ergochat/ergo/irc/datastore"
|
||||||
"github.com/ergochat/ergo/irc/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 {
|
||||||
@ -33,7 +37,6 @@ type Channel struct {
|
|||||||
key string
|
key string
|
||||||
forward string
|
forward string
|
||||||
members MemberSet
|
members MemberSet
|
||||||
membersCache []*Client // allow iteration over channel members without holding the lock
|
|
||||||
name string
|
name string
|
||||||
nameCasefolded string
|
nameCasefolded string
|
||||||
server *Server
|
server *Server
|
||||||
@ -50,14 +53,18 @@ type Channel struct {
|
|||||||
stateMutex sync.RWMutex // tier 1
|
stateMutex sync.RWMutex // tier 1
|
||||||
writebackLock sync.Mutex // tier 1.5
|
writebackLock sync.Mutex // tier 1.5
|
||||||
joinPartMutex sync.Mutex // tier 3
|
joinPartMutex sync.Mutex // tier 3
|
||||||
ensureLoaded utils.Once // manages loading stored registration info from the database
|
|
||||||
dirtyBits uint
|
dirtyBits uint
|
||||||
settings ChannelSettings
|
settings ChannelSettings
|
||||||
|
uuid utils.UUID
|
||||||
|
metadata map[string]string
|
||||||
|
// these caches are paired to allow iteration over channel members without holding the lock
|
||||||
|
membersCache []*Client
|
||||||
|
memberDataCache []*memberData
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChannel creates a new channel from a `Server` and a `name`
|
// NewChannel creates a new channel from a `Server` and a `name`
|
||||||
// string, which must be unique on the server.
|
// string, which must be unique on the server.
|
||||||
func NewChannel(s *Server, name, casefoldedName string, registered bool) *Channel {
|
func NewChannel(s *Server, name, casefoldedName string, registered bool, regInfo RegisteredChannel) *Channel {
|
||||||
config := s.Config()
|
config := s.Config()
|
||||||
|
|
||||||
channel := &Channel{
|
channel := &Channel{
|
||||||
@ -71,14 +78,15 @@ func NewChannel(s *Server, name, casefoldedName string, registered bool) *Channe
|
|||||||
channel.initializeLists()
|
channel.initializeLists()
|
||||||
channel.history.Initialize(0, 0)
|
channel.history.Initialize(0, 0)
|
||||||
|
|
||||||
if !registered {
|
if registered {
|
||||||
|
channel.applyRegInfo(regInfo)
|
||||||
|
} else {
|
||||||
channel.resizeHistory(config)
|
channel.resizeHistory(config)
|
||||||
for _, mode := range config.Channels.defaultModes {
|
for _, mode := range config.Channels.defaultModes {
|
||||||
channel.flags.SetMode(mode, true)
|
channel.flags.SetMode(mode, true)
|
||||||
}
|
}
|
||||||
// no loading to do, so "mark" the load operation as "done":
|
channel.uuid = utils.GenerateUUIDv4()
|
||||||
channel.ensureLoaded.Do(func() {})
|
}
|
||||||
} // else: modes will be loaded before first join
|
|
||||||
|
|
||||||
return channel
|
return channel
|
||||||
}
|
}
|
||||||
@ -92,24 +100,6 @@ func (channel *Channel) initializeLists() {
|
|||||||
channel.accountToUMode = make(map[string]modes.Mode)
|
channel.accountToUMode = make(map[string]modes.Mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureLoaded blocks until the channel's registration info has been loaded
|
|
||||||
// from the database.
|
|
||||||
func (channel *Channel) EnsureLoaded() {
|
|
||||||
channel.ensureLoaded.Do(func() {
|
|
||||||
nmc := channel.NameCasefolded()
|
|
||||||
info, err := channel.server.channelRegistry.LoadChannel(nmc)
|
|
||||||
if err == nil {
|
|
||||||
channel.applyRegInfo(info)
|
|
||||||
} else {
|
|
||||||
channel.server.logger.Error("internal", "couldn't load channel", nmc, err.Error())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (channel *Channel) IsLoaded() bool {
|
|
||||||
return channel.ensureLoaded.Done()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (channel *Channel) resizeHistory(config *Config) {
|
func (channel *Channel) resizeHistory(config *Config) {
|
||||||
status, _, _ := channel.historyStatus(config)
|
status, _, _ := channel.historyStatus(config)
|
||||||
if status == HistoryEphemeral {
|
if status == HistoryEphemeral {
|
||||||
@ -126,6 +116,7 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
|
|||||||
channel.stateMutex.Lock()
|
channel.stateMutex.Lock()
|
||||||
defer channel.stateMutex.Unlock()
|
defer channel.stateMutex.Unlock()
|
||||||
|
|
||||||
|
channel.uuid = chanReg.UUID
|
||||||
channel.registeredFounder = chanReg.Founder
|
channel.registeredFounder = chanReg.Founder
|
||||||
channel.registeredTime = chanReg.RegisteredAt
|
channel.registeredTime = chanReg.RegisteredAt
|
||||||
channel.topic = chanReg.Topic
|
channel.topic = chanReg.Topic
|
||||||
@ -137,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)
|
||||||
@ -150,38 +142,42 @@ func (channel *Channel) applyRegInfo(chanReg RegisteredChannel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// obtain a consistent snapshot of the channel state that can be persisted to the DB
|
// obtain a consistent snapshot of the channel state that can be persisted to the DB
|
||||||
func (channel *Channel) ExportRegistration(includeFlags uint) (info RegisteredChannel) {
|
func (channel *Channel) ExportRegistration() (info RegisteredChannel) {
|
||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
defer channel.stateMutex.RUnlock()
|
defer channel.stateMutex.RUnlock()
|
||||||
|
|
||||||
info.Name = channel.name
|
info.Name = channel.name
|
||||||
info.NameCasefolded = channel.nameCasefolded
|
info.UUID = channel.uuid
|
||||||
info.Founder = channel.registeredFounder
|
info.Founder = channel.registeredFounder
|
||||||
info.RegisteredAt = channel.registeredTime
|
info.RegisteredAt = channel.registeredTime
|
||||||
|
|
||||||
if includeFlags&IncludeTopic != 0 {
|
info.Topic = channel.topic
|
||||||
info.Topic = channel.topic
|
info.TopicSetBy = channel.topicSetBy
|
||||||
info.TopicSetBy = channel.topicSetBy
|
info.TopicSetTime = channel.topicSetTime
|
||||||
info.TopicSetTime = channel.topicSetTime
|
|
||||||
}
|
|
||||||
|
|
||||||
if includeFlags&IncludeModes != 0 {
|
info.Key = channel.key
|
||||||
info.Key = channel.key
|
info.Forward = channel.forward
|
||||||
info.Forward = channel.forward
|
info.Modes = channel.flags.AllModes()
|
||||||
info.Modes = channel.flags.AllModes()
|
info.UserLimit = channel.userLimit
|
||||||
info.UserLimit = channel.userLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
if includeFlags&IncludeLists != 0 {
|
info.Bans = channel.lists[modes.BanMask].Masks()
|
||||||
info.Bans = channel.lists[modes.BanMask].Masks()
|
info.Invites = channel.lists[modes.InviteMask].Masks()
|
||||||
info.Invites = channel.lists[modes.InviteMask].Masks()
|
info.Excepts = channel.lists[modes.ExceptMask].Masks()
|
||||||
info.Excepts = channel.lists[modes.ExceptMask].Masks()
|
info.AccountToUMode = maps.Clone(channel.accountToUMode)
|
||||||
info.AccountToUMode = utils.CopyMap(channel.accountToUMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if includeFlags&IncludeSettings != 0 {
|
info.Settings = channel.settings
|
||||||
info.Settings = channel.settings
|
info.Metadata = channel.metadata
|
||||||
}
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channel *Channel) exportSummary() (info RegisteredChannel) {
|
||||||
|
channel.stateMutex.RLock()
|
||||||
|
defer channel.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
info.Name = channel.name
|
||||||
|
info.Founder = channel.registeredFounder
|
||||||
|
info.RegisteredAt = channel.registeredTime
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -231,6 +227,8 @@ func (channel *Channel) wakeWriter() {
|
|||||||
|
|
||||||
// equivalent of Socket.send()
|
// equivalent of Socket.send()
|
||||||
func (channel *Channel) writeLoop() {
|
func (channel *Channel) writeLoop() {
|
||||||
|
defer channel.server.HandlePanic(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
|
||||||
channel.performWrite(0)
|
channel.performWrite(0)
|
||||||
@ -288,9 +286,19 @@ func (channel *Channel) performWrite(additionalDirtyBits uint) (err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
info := channel.ExportRegistration(dirtyBits)
|
var success bool
|
||||||
err = channel.server.channelRegistry.StoreChannel(info, dirtyBits)
|
info := channel.ExportRegistration()
|
||||||
if err != nil {
|
if b, err := info.Serialize(); err == nil {
|
||||||
|
if err := channel.server.dstore.Set(datastore.TableChannels, info.UUID, b, time.Time{}); err == nil {
|
||||||
|
success = true
|
||||||
|
} else {
|
||||||
|
channel.server.logger.Error("internal", "couldn't persist channel", info.Name, err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.server.logger.Error("internal", "couldn't serialize channel", info.Name, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
channel.stateMutex.Lock()
|
channel.stateMutex.Lock()
|
||||||
channel.dirtyBits = channel.dirtyBits | dirtyBits
|
channel.dirtyBits = channel.dirtyBits | dirtyBits
|
||||||
channel.stateMutex.Unlock()
|
channel.stateMutex.Unlock()
|
||||||
@ -314,6 +322,7 @@ func (channel *Channel) SetRegistered(founder string) error {
|
|||||||
|
|
||||||
// SetUnregistered deletes the channel's registration information.
|
// SetUnregistered deletes the channel's registration information.
|
||||||
func (channel *Channel) SetUnregistered(expectedFounder string) {
|
func (channel *Channel) SetUnregistered(expectedFounder string) {
|
||||||
|
uuid := utils.GenerateUUIDv4()
|
||||||
channel.stateMutex.Lock()
|
channel.stateMutex.Lock()
|
||||||
defer channel.stateMutex.Unlock()
|
defer channel.stateMutex.Unlock()
|
||||||
|
|
||||||
@ -324,6 +333,9 @@ func (channel *Channel) SetUnregistered(expectedFounder string) {
|
|||||||
var zeroTime time.Time
|
var zeroTime time.Time
|
||||||
channel.registeredTime = zeroTime
|
channel.registeredTime = zeroTime
|
||||||
channel.accountToUMode = make(map[string]modes.Mode)
|
channel.accountToUMode = make(map[string]modes.Mode)
|
||||||
|
// reset the UUID so that any re-registration will persist under
|
||||||
|
// a separate key:
|
||||||
|
channel.uuid = uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
// implements `CHANSERV CLEAR #chan ACCESS` (resets bans, invites, excepts, and amodes)
|
// implements `CHANSERV CLEAR #chan ACCESS` (resets bans, invites, excepts, and amodes)
|
||||||
@ -419,16 +431,19 @@ func (channel *Channel) AcceptTransfer(client *Client) (err error) {
|
|||||||
|
|
||||||
func (channel *Channel) regenerateMembersCache() {
|
func (channel *Channel) regenerateMembersCache() {
|
||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
result := make([]*Client, len(channel.members))
|
membersCache := make([]*Client, len(channel.members))
|
||||||
|
dataCache := make([]*memberData, len(channel.members))
|
||||||
i := 0
|
i := 0
|
||||||
for client := range channel.members {
|
for client, info := range channel.members {
|
||||||
result[i] = client
|
membersCache[i] = client
|
||||||
|
dataCache[i] = info
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
channel.stateMutex.RUnlock()
|
channel.stateMutex.RUnlock()
|
||||||
|
|
||||||
channel.stateMutex.Lock()
|
channel.stateMutex.Lock()
|
||||||
channel.membersCache = result
|
channel.membersCache = membersCache
|
||||||
|
channel.memberDataCache = dataCache
|
||||||
channel.stateMutex.Unlock()
|
channel.stateMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -436,59 +451,45 @@ func (channel *Channel) regenerateMembersCache() {
|
|||||||
func (channel *Channel) Names(client *Client, rb *ResponseBuffer) {
|
func (channel *Channel) Names(client *Client, rb *ResponseBuffer) {
|
||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
clientData, isJoined := channel.members[client]
|
clientData, isJoined := channel.members[client]
|
||||||
|
chname := channel.name
|
||||||
|
membersCache, memberDataCache := channel.membersCache, channel.memberDataCache
|
||||||
channel.stateMutex.RUnlock()
|
channel.stateMutex.RUnlock()
|
||||||
|
symbol := "=" // https://modern.ircdocs.horse/#rplnamreply-353
|
||||||
|
if channel.flags.HasMode(modes.Secret) {
|
||||||
|
symbol = "@"
|
||||||
|
}
|
||||||
isOper := client.HasRoleCapabs("sajoin")
|
isOper := client.HasRoleCapabs("sajoin")
|
||||||
respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper &&
|
respectAuditorium := channel.flags.HasMode(modes.Auditorium) && !isOper &&
|
||||||
(!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0))
|
(!isJoined || clientData.modes.HighestChannelUserMode() == modes.Mode(0))
|
||||||
isMultiPrefix := rb.session.capabilities.Has(caps.MultiPrefix)
|
isMultiPrefix := rb.session.capabilities.Has(caps.MultiPrefix)
|
||||||
isUserhostInNames := rb.session.capabilities.Has(caps.UserhostInNames)
|
isUserhostInNames := rb.session.capabilities.Has(caps.UserhostInNames)
|
||||||
|
|
||||||
maxNamLen := 480 - len(client.server.name) - len(client.Nick())
|
maxNamLen := 480 - len(client.server.name) - len(client.Nick()) - len(chname)
|
||||||
var namesLines []string
|
var tl utils.TokenLineBuilder
|
||||||
var buffer strings.Builder
|
tl.Initialize(maxNamLen, " ")
|
||||||
if isJoined || !channel.flags.HasMode(modes.Secret) || isOper {
|
if isJoined || !channel.flags.HasMode(modes.Secret) || isOper {
|
||||||
for _, target := range channel.Members() {
|
for i, target := range membersCache {
|
||||||
|
if !isJoined && target.HasMode(modes.Invisible) && !isOper {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var nick string
|
var nick string
|
||||||
if isUserhostInNames {
|
if isUserhostInNames {
|
||||||
nick = target.NickMaskString()
|
nick = target.NickMaskString()
|
||||||
} else {
|
} else {
|
||||||
nick = target.Nick()
|
nick = target.Nick()
|
||||||
}
|
}
|
||||||
channel.stateMutex.RLock()
|
memberData := memberDataCache[i]
|
||||||
memberData, _ := channel.members[target]
|
if respectAuditorium && memberData.modes.HighestChannelUserMode() == modes.Mode(0) {
|
||||||
channel.stateMutex.RUnlock()
|
|
||||||
modeSet := memberData.modes
|
|
||||||
if modeSet == nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !isJoined && target.HasMode(modes.Invisible) && !isOper {
|
tl.AddParts(memberData.modes.Prefixes(isMultiPrefix), nick)
|
||||||
continue
|
|
||||||
}
|
|
||||||
if respectAuditorium && modeSet.HighestChannelUserMode() == modes.Mode(0) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
prefix := modeSet.Prefixes(isMultiPrefix)
|
|
||||||
if buffer.Len()+len(nick)+len(prefix)+1 > maxNamLen {
|
|
||||||
namesLines = append(namesLines, buffer.String())
|
|
||||||
buffer.Reset()
|
|
||||||
}
|
|
||||||
if buffer.Len() > 0 {
|
|
||||||
buffer.WriteString(" ")
|
|
||||||
}
|
|
||||||
buffer.WriteString(prefix)
|
|
||||||
buffer.WriteString(nick)
|
|
||||||
}
|
|
||||||
if buffer.Len() > 0 {
|
|
||||||
namesLines = append(namesLines, buffer.String())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, line := range namesLines {
|
for _, line := range tl.Lines() {
|
||||||
if buffer.Len() > 0 {
|
rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, symbol, chname, line)
|
||||||
rb.Add(nil, client.server.name, RPL_NAMREPLY, client.nick, "=", channel.name, line)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
rb.Add(nil, client.server.name, RPL_ENDOFNAMES, client.nick, channel.name, client.t("End of NAMES list"))
|
rb.Add(nil, client.server.name, RPL_ENDOFNAMES, client.nick, chname, client.t("End of NAMES list"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// does `clientMode` give you privileges to grant/remove `targetMode` to/from people,
|
// does `clientMode` give you privileges to grant/remove `targetMode` to/from people,
|
||||||
@ -512,7 +513,7 @@ func channelUserModeHasPrivsOver(clientMode modes.Mode, targetMode modes.Mode) b
|
|||||||
// ClientIsAtLeast returns whether the client has at least the given channel privilege.
|
// ClientIsAtLeast returns whether the client has at least the given channel privilege.
|
||||||
func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) bool {
|
func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) bool {
|
||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
memberData := channel.members[client]
|
memberData, present := channel.members[client]
|
||||||
founder := channel.registeredFounder
|
founder := channel.registeredFounder
|
||||||
channel.stateMutex.RUnlock()
|
channel.stateMutex.RUnlock()
|
||||||
|
|
||||||
@ -520,6 +521,10 @@ func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) b
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !present {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
for _, mode := range modes.ChannelUserModes {
|
for _, mode := range modes.ChannelUserModes {
|
||||||
if memberData.modes.HasMode(mode) {
|
if memberData.modes.HasMode(mode) {
|
||||||
return true
|
return true
|
||||||
@ -546,16 +551,23 @@ func (channel *Channel) ClientStatus(client *Client) (present bool, joinTimeSecs
|
|||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
defer channel.stateMutex.RUnlock()
|
defer channel.stateMutex.RUnlock()
|
||||||
memberData, present := channel.members[client]
|
memberData, present := channel.members[client]
|
||||||
return present, time.Unix(0, memberData.joinTime).Unix(), memberData.modes.AllModes()
|
if present {
|
||||||
|
return present, time.Unix(0, memberData.joinTime).Unix(), memberData.modes.AllModes()
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// helper for persisting channel-user modes for always-on clients;
|
// helper for persisting channel-user modes for always-on clients;
|
||||||
// return the channel name and all channel-user modes for a client
|
// return the channel name and all channel-user modes for a client
|
||||||
func (channel *Channel) alwaysOnStatus(client *Client) (chname string, status alwaysOnChannelStatus) {
|
func (channel *Channel) alwaysOnStatus(client *Client) (ok bool, chname string, status alwaysOnChannelStatus) {
|
||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
defer channel.stateMutex.RUnlock()
|
defer channel.stateMutex.RUnlock()
|
||||||
chname = channel.name
|
chname = channel.name
|
||||||
data := channel.members[client]
|
data, ok := channel.members[client]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
status.Modes = data.modes.String()
|
status.Modes = data.modes.String()
|
||||||
status.JoinTime = data.joinTime
|
status.JoinTime = data.joinTime
|
||||||
return
|
return
|
||||||
@ -569,20 +581,20 @@ func (channel *Channel) setMemberStatus(client *Client, status alwaysOnChannelSt
|
|||||||
}
|
}
|
||||||
channel.stateMutex.Lock()
|
channel.stateMutex.Lock()
|
||||||
defer channel.stateMutex.Unlock()
|
defer channel.stateMutex.Unlock()
|
||||||
if _, ok := channel.members[client]; !ok {
|
if mData, ok := channel.members[client]; ok {
|
||||||
return
|
mData.modes.Clear()
|
||||||
|
for _, mode := range status.Modes {
|
||||||
|
mData.modes.SetMode(modes.Mode(mode), true)
|
||||||
|
}
|
||||||
|
mData.joinTime = status.JoinTime
|
||||||
}
|
}
|
||||||
memberData := channel.members[client]
|
|
||||||
memberData.modes = newModes
|
|
||||||
memberData.joinTime = status.JoinTime
|
|
||||||
channel.members[client] = memberData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (channel *Channel) ClientHasPrivsOver(client *Client, target *Client) bool {
|
func (channel *Channel) ClientHasPrivsOver(client *Client, target *Client) bool {
|
||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
founder := channel.registeredFounder
|
founder := channel.registeredFounder
|
||||||
clientModes := channel.members[client].modes
|
clientData, clientOK := channel.members[client]
|
||||||
targetModes := channel.members[target].modes
|
targetData, targetOK := channel.members[target]
|
||||||
channel.stateMutex.RUnlock()
|
channel.stateMutex.RUnlock()
|
||||||
|
|
||||||
if founder != "" {
|
if founder != "" {
|
||||||
@ -593,7 +605,11 @@ func (channel *Channel) ClientHasPrivsOver(client *Client, target *Client) bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return channelUserModeHasPrivsOver(clientModes.HighestChannelUserMode(), targetModes.HighestChannelUserMode())
|
return clientOK && targetOK &&
|
||||||
|
channelUserModeHasPrivsOver(
|
||||||
|
clientData.modes.HighestChannelUserMode(),
|
||||||
|
targetData.modes.HighestChannelUserMode(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (channel *Channel) hasClient(client *Client) bool {
|
func (channel *Channel) hasClient(client *Client) bool {
|
||||||
@ -718,6 +734,9 @@ func (channel *Channel) AddHistoryItem(item history.Item, account string) (err e
|
|||||||
status, target, _ := channel.historyStatus(channel.server.Config())
|
status, target, _ := channel.historyStatus(channel.server.Config())
|
||||||
if status == HistoryPersistent {
|
if status == HistoryPersistent {
|
||||||
err = channel.server.historyDB.AddChannelItem(target, item, account)
|
err = channel.server.historyDB.AddChannelItem(target, item, account)
|
||||||
|
if err != nil {
|
||||||
|
channel.server.logger.Error("history", "could not add channel message to history", err.Error())
|
||||||
|
}
|
||||||
} else if status == HistoryEphemeral {
|
} else if status == HistoryEphemeral {
|
||||||
channel.history.Add(item)
|
channel.history.Add(item)
|
||||||
}
|
}
|
||||||
@ -884,10 +903,16 @@ 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)
|
||||||
channel.Names(client, rb)
|
if !rb.session.capabilities.Has(caps.NoImplicitNames) {
|
||||||
|
channel.Names(client, rb)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// ensure that SAJOIN sends a MODE line to the originating client, if applicable
|
// ensure that SAJOIN sends a MODE line to the originating client, if applicable
|
||||||
if givenMode != 0 {
|
if givenMode != 0 {
|
||||||
@ -978,7 +1003,9 @@ func (channel *Channel) playJoinForSession(session *Session) {
|
|||||||
sessionRb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname))
|
sessionRb.Add(nil, client.server.name, "MARKREAD", chname, client.GetReadMarker(chcfname))
|
||||||
}
|
}
|
||||||
channel.SendTopic(client, sessionRb, false)
|
channel.SendTopic(client, sessionRb, false)
|
||||||
channel.Names(client, sessionRb)
|
if !session.capabilities.Has(caps.NoImplicitNames) {
|
||||||
|
channel.Names(client, sessionRb)
|
||||||
|
}
|
||||||
sessionRb.Send(false)
|
sessionRb.Send(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1062,7 +1089,7 @@ func (channel *Channel) replayHistoryItems(rb *ResponseBuffer, items []history.I
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
batchID := rb.StartNestedHistoryBatch(chname)
|
batchID := rb.StartNestedBatch("chathistory", chname)
|
||||||
defer rb.EndNestedBatch(batchID)
|
defer rb.EndNestedBatch(batchID)
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@ -1194,7 +1221,7 @@ func (channel *Channel) SetTopic(client *Client, topic string, rb *ResponseBuffe
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
topic = ircutils.TruncateUTF8Safe(topic, client.server.Config().Limits.TopicLen)
|
topic = ircmsg.TruncateUTF8Safe(topic, client.server.Config().Limits.TopicLen)
|
||||||
|
|
||||||
channel.stateMutex.Lock()
|
channel.stateMutex.Lock()
|
||||||
chname := channel.name
|
chname := channel.name
|
||||||
@ -1231,20 +1258,26 @@ func (channel *Channel) CanSpeak(client *Client) (bool, modes.Mode) {
|
|||||||
channel.stateMutex.RLock()
|
channel.stateMutex.RLock()
|
||||||
memberData, hasClient := channel.members[client]
|
memberData, hasClient := channel.members[client]
|
||||||
channel.stateMutex.RUnlock()
|
channel.stateMutex.RUnlock()
|
||||||
clientModes := memberData.modes
|
|
||||||
|
highestMode := func() modes.Mode {
|
||||||
|
if !hasClient {
|
||||||
|
return modes.Mode(0)
|
||||||
|
}
|
||||||
|
return memberData.modes.HighestChannelUserMode()
|
||||||
|
}
|
||||||
|
|
||||||
if !hasClient && channel.flags.HasMode(modes.NoOutside) {
|
if !hasClient && channel.flags.HasMode(modes.NoOutside) {
|
||||||
// TODO: enforce regular +b bans on -n channels?
|
// TODO: enforce regular +b bans on -n channels?
|
||||||
return false, modes.NoOutside
|
return false, modes.NoOutside
|
||||||
}
|
}
|
||||||
if channel.isMuted(client) && clientModes.HighestChannelUserMode() == modes.Mode(0) {
|
if channel.isMuted(client) && highestMode() == modes.Mode(0) {
|
||||||
return false, modes.BanMask
|
return false, modes.BanMask
|
||||||
}
|
}
|
||||||
if channel.flags.HasMode(modes.Moderated) && clientModes.HighestChannelUserMode() == modes.Mode(0) {
|
if channel.flags.HasMode(modes.Moderated) && highestMode() == modes.Mode(0) {
|
||||||
return false, modes.Moderated
|
return false, modes.Moderated
|
||||||
}
|
}
|
||||||
if channel.flags.HasMode(modes.RegisteredOnlySpeak) && client.Account() == "" &&
|
if channel.flags.HasMode(modes.RegisteredOnlySpeak) && client.Account() == "" &&
|
||||||
clientModes.HighestChannelUserMode() == modes.Mode(0) {
|
highestMode() == modes.Mode(0) {
|
||||||
return false, modes.RegisteredOnlySpeak
|
return false, modes.RegisteredOnlySpeak
|
||||||
}
|
}
|
||||||
return true, modes.Mode('?')
|
return true, modes.Mode('?')
|
||||||
@ -1303,23 +1336,26 @@ 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 := channel.members[client]
|
cuData, ok := channel.members[client]
|
||||||
channel.stateMutex.RUnlock()
|
channel.stateMutex.RUnlock()
|
||||||
if cuData.modes.HighestChannelUserMode() == modes.Mode(0) {
|
if !ok || cuData.modes.HighestChannelUserMode() == modes.Mode(0) {
|
||||||
// max(statusmsg_minmode, halfop)
|
// max(statusmsg_minmode, halfop)
|
||||||
if minPrefixMode == modes.Mode(0) || minPrefixMode == modes.Voice {
|
if minPrefixMode == modes.Mode(0) || minPrefixMode == modes.Voice {
|
||||||
minPrefixMode = modes.Halfop
|
minPrefixMode = modes.Halfop
|
||||||
@ -1338,6 +1374,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO consider when we might want to push TAGMSG
|
||||||
|
dispatchWebPush = dispatchWebPush || (config.WebPush.Enabled && histType != history.Tagmsg && member.hasPushSubscriptions())
|
||||||
|
|
||||||
for _, session := range member.Sessions() {
|
for _, session := range member.Sessions() {
|
||||||
if session == rb.session {
|
if session == rb.session {
|
||||||
continue // we already sent echo-message, if applicable
|
continue // we already sent echo-message, if applicable
|
||||||
@ -1361,6 +1400,42 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
|||||||
Tags: clientOnlyTags,
|
Tags: clientOnlyTags,
|
||||||
IsBot: isBot,
|
IsBot: isBot,
|
||||||
}, details.account)
|
}, details.account)
|
||||||
|
|
||||||
|
if dispatchWebPush {
|
||||||
|
channel.dispatchWebPush(client, command, details.nickMask, details.accountName, chname, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channel *Channel) dispatchWebPush(client *Client, command, nuh, accountName, chname string, msg utils.SplitMessage) {
|
||||||
|
msgBytes, err := webpush.MakePushMessage(command, nuh, accountName, chname, msg)
|
||||||
|
if err != nil {
|
||||||
|
channel.server.logger.Error("internal", "can't serialize push message", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageText := strings.ToLower(msg.CombinedValue())
|
||||||
|
|
||||||
|
for _, member := range channel.Members() {
|
||||||
|
if member == client {
|
||||||
|
continue // don't push to the client's own devices even if they mentioned themself
|
||||||
|
}
|
||||||
|
if !member.hasPushSubscriptions() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// this is the casefolded account name for comparison to the casefolded message text:
|
||||||
|
account := member.Account()
|
||||||
|
if account == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !webpush.IsHighlight(messageText, account) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
member.dispatchPushMessage(pushMessage{
|
||||||
|
msg: msgBytes,
|
||||||
|
urgency: webpush.UrgencyHigh,
|
||||||
|
cftarget: channel.NameCasefolded(),
|
||||||
|
time: msg.Time,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1402,8 +1477,8 @@ func (channel *Channel) ShowMaskList(client *Client, mode modes.Mode, rb *Respon
|
|||||||
rpllist = RPL_EXCEPTLIST
|
rpllist = RPL_EXCEPTLIST
|
||||||
rplendoflist = RPL_ENDOFEXCEPTLIST
|
rplendoflist = RPL_ENDOFEXCEPTLIST
|
||||||
} else if mode == modes.InviteMask {
|
} else if mode == modes.InviteMask {
|
||||||
rpllist = RPL_INVITELIST
|
rpllist = RPL_INVEXLIST
|
||||||
rplendoflist = RPL_ENDOFINVITELIST
|
rplendoflist = RPL_ENDOFINVEXLIST
|
||||||
}
|
}
|
||||||
|
|
||||||
nick := client.Nick()
|
nick := client.Nick()
|
||||||
@ -1447,7 +1522,7 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
comment = ircutils.TruncateUTF8Safe(comment, channel.server.Config().Limits.KickLen)
|
comment = ircmsg.TruncateUTF8Safe(comment, channel.server.Config().Limits.KickLen)
|
||||||
|
|
||||||
message := utils.MakeMessage(comment)
|
message := utils.MakeMessage(comment)
|
||||||
details := client.Details()
|
details := client.Details()
|
||||||
@ -1488,6 +1563,7 @@ func (channel *Channel) Purge(source string) {
|
|||||||
chname := channel.name
|
chname := channel.name
|
||||||
members := channel.membersCache
|
members := channel.membersCache
|
||||||
channel.membersCache = nil
|
channel.membersCache = nil
|
||||||
|
channel.memberDataCache = nil
|
||||||
channel.members = make(MemberSet)
|
channel.members = make(MemberSet)
|
||||||
// TODO try to prevent Purge racing against (pending) Join?
|
// TODO try to prevent Purge racing against (pending) Join?
|
||||||
channel.stateMutex.Unlock()
|
channel.stateMutex.Unlock()
|
||||||
@ -1608,6 +1684,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()
|
||||||
|
|||||||
@ -6,7 +6,9 @@ package irc
|
|||||||
import (
|
import (
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/datastore"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -25,85 +27,75 @@ type channelManagerEntry struct {
|
|||||||
type ChannelManager struct {
|
type ChannelManager struct {
|
||||||
sync.RWMutex // tier 2
|
sync.RWMutex // tier 2
|
||||||
// chans is the main data structure, mapping casefolded name -> *Channel
|
// chans is the main data structure, mapping casefolded name -> *Channel
|
||||||
chans map[string]*channelManagerEntry
|
chans map[string]*channelManagerEntry
|
||||||
chansSkeletons utils.HashSet[string] // skeletons of *unregistered* chans
|
chansSkeletons utils.HashSet[string]
|
||||||
registeredChannels utils.HashSet[string] // casefolds of registered chans
|
purgedChannels map[string]ChannelPurgeRecord // casefolded name to purge record
|
||||||
registeredSkeletons utils.HashSet[string] // skeletons of registered chans
|
server *Server
|
||||||
purgedChannels utils.HashSet[string] // casefolds of purged chans
|
|
||||||
server *Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChannelManager returns a new ChannelManager.
|
// NewChannelManager returns a new ChannelManager.
|
||||||
func (cm *ChannelManager) Initialize(server *Server) {
|
func (cm *ChannelManager) Initialize(server *Server, config *Config) (err error) {
|
||||||
cm.chans = make(map[string]*channelManagerEntry)
|
cm.chans = make(map[string]*channelManagerEntry)
|
||||||
cm.chansSkeletons = make(utils.HashSet[string])
|
cm.chansSkeletons = make(utils.HashSet[string])
|
||||||
cm.server = server
|
cm.server = server
|
||||||
|
return cm.loadRegisteredChannels(config)
|
||||||
// purging should work even if registration is disabled
|
|
||||||
cm.purgedChannels = cm.server.channelRegistry.PurgedChannels()
|
|
||||||
cm.loadRegisteredChannels(server.Config())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *ChannelManager) loadRegisteredChannels(config *Config) {
|
func (cm *ChannelManager) loadRegisteredChannels(config *Config) (err error) {
|
||||||
if !config.Channels.Registration.Enabled {
|
allChannels, err := FetchAndDeserializeAll[RegisteredChannel](datastore.TableChannels, cm.server.dstore, cm.server.logger)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allPurgeRecords, err := FetchAndDeserializeAll[ChannelPurgeRecord](datastore.TableChannelPurges, cm.server.dstore, cm.server.logger)
|
||||||
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var newChannels []*Channel
|
|
||||||
var collisions []string
|
|
||||||
defer func() {
|
|
||||||
for _, ch := range newChannels {
|
|
||||||
ch.EnsureLoaded()
|
|
||||||
cm.server.logger.Debug("channels", "initialized registered channel", ch.Name())
|
|
||||||
}
|
|
||||||
for _, collision := range collisions {
|
|
||||||
cm.server.logger.Warning("channels", "registered channel collides with existing channel", collision)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
rawNames := cm.server.channelRegistry.AllChannels()
|
|
||||||
|
|
||||||
cm.Lock()
|
cm.Lock()
|
||||||
defer cm.Unlock()
|
defer cm.Unlock()
|
||||||
|
|
||||||
cm.registeredChannels = make(utils.HashSet[string], len(rawNames))
|
cm.purgedChannels = make(map[string]ChannelPurgeRecord, len(allPurgeRecords))
|
||||||
cm.registeredSkeletons = make(utils.HashSet[string], len(rawNames))
|
for _, purge := range allPurgeRecords {
|
||||||
for _, name := range rawNames {
|
cm.purgedChannels[purge.NameCasefolded] = purge
|
||||||
cfname, err := CasefoldChannel(name)
|
}
|
||||||
if err == nil {
|
|
||||||
cm.registeredChannels.Add(cfname)
|
for _, regInfo := range allChannels {
|
||||||
|
cfname, err := CasefoldChannel(regInfo.Name)
|
||||||
|
if err != nil {
|
||||||
|
cm.server.logger.Error("channels", "couldn't casefold registered channel, skipping", regInfo.Name, err.Error())
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
cm.server.logger.Debug("channels", "initializing registered channel", regInfo.Name)
|
||||||
}
|
}
|
||||||
skeleton, err := Skeleton(name)
|
skeleton, err := Skeleton(regInfo.Name)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
cm.registeredSkeletons.Add(skeleton)
|
cm.chansSkeletons.Add(skeleton)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cm.purgedChannels.Has(cfname) {
|
if _, ok := cm.purgedChannels[cfname]; !ok {
|
||||||
if _, ok := cm.chans[cfname]; !ok {
|
ch := NewChannel(cm.server, regInfo.Name, cfname, true, regInfo)
|
||||||
ch := NewChannel(cm.server, name, cfname, true)
|
cm.chans[cfname] = &channelManagerEntry{
|
||||||
cm.chans[cfname] = &channelManagerEntry{
|
channel: ch,
|
||||||
channel: ch,
|
pendingJoins: 0,
|
||||||
pendingJoins: 0,
|
skeleton: skeleton,
|
||||||
}
|
|
||||||
newChannels = append(newChannels, ch)
|
|
||||||
} else {
|
|
||||||
collisions = append(collisions, name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns an existing channel with name equivalent to `name`, or nil
|
// Get returns an existing channel with name equivalent to `name`, or nil
|
||||||
func (cm *ChannelManager) Get(name string) (channel *Channel) {
|
func (cm *ChannelManager) Get(name string) (channel *Channel) {
|
||||||
name, err := CasefoldChannel(name)
|
name, err := CasefoldChannel(name)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
cm.RLock()
|
return nil
|
||||||
defer cm.RUnlock()
|
}
|
||||||
entry := cm.chans[name]
|
cm.RLock()
|
||||||
// if the channel is still loading, pretend we don't have it
|
defer cm.RUnlock()
|
||||||
if entry != nil && entry.channel.IsLoaded() {
|
entry := cm.chans[name]
|
||||||
return entry.channel
|
if entry != nil {
|
||||||
}
|
return entry.channel
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -122,33 +114,26 @@ func (cm *ChannelManager) Join(client *Client, name string, key string, isSajoin
|
|||||||
cm.Lock()
|
cm.Lock()
|
||||||
defer cm.Unlock()
|
defer cm.Unlock()
|
||||||
|
|
||||||
if cm.purgedChannels.Has(casefoldedName) {
|
// check purges first; a registered purged channel will still be present in `chans`
|
||||||
|
if _, ok := cm.purgedChannels[casefoldedName]; ok {
|
||||||
return nil, errChannelPurged, false
|
return nil, errChannelPurged, false
|
||||||
}
|
}
|
||||||
entry := cm.chans[casefoldedName]
|
entry := cm.chans[casefoldedName]
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
registered := cm.registeredChannels.Has(casefoldedName)
|
if server.Config().Channels.OpOnlyCreation &&
|
||||||
// enforce OpOnlyCreation
|
|
||||||
if !registered && server.Config().Channels.OpOnlyCreation &&
|
|
||||||
!(isSajoin || client.HasRoleCapabs("chanreg")) {
|
!(isSajoin || client.HasRoleCapabs("chanreg")) {
|
||||||
return nil, errInsufficientPrivs, false
|
return nil, errInsufficientPrivs, false
|
||||||
}
|
}
|
||||||
// enforce confusables
|
// enforce confusables
|
||||||
if !registered && (cm.chansSkeletons.Has(skeleton) || cm.registeredSkeletons.Has(skeleton)) {
|
if cm.chansSkeletons.Has(skeleton) {
|
||||||
return nil, errConfusableIdentifier, false
|
return nil, errConfusableIdentifier, false
|
||||||
}
|
}
|
||||||
entry = &channelManagerEntry{
|
entry = &channelManagerEntry{
|
||||||
channel: NewChannel(server, name, casefoldedName, registered),
|
channel: NewChannel(server, name, casefoldedName, false, RegisteredChannel{}),
|
||||||
pendingJoins: 0,
|
pendingJoins: 0,
|
||||||
}
|
}
|
||||||
if !registered {
|
cm.chansSkeletons.Add(skeleton)
|
||||||
// for an unregistered channel, we already have the correct unfolded name
|
entry.skeleton = skeleton
|
||||||
// and therefore the final skeleton. for a registered channel, we don't have
|
|
||||||
// the unfolded name yet (it needs to be loaded from the db), but we already
|
|
||||||
// have the final skeleton in `registeredSkeletons` so we don't need to track it
|
|
||||||
cm.chansSkeletons.Add(skeleton)
|
|
||||||
entry.skeleton = skeleton
|
|
||||||
}
|
|
||||||
cm.chans[casefoldedName] = entry
|
cm.chans[casefoldedName] = entry
|
||||||
newChannel = true
|
newChannel = true
|
||||||
}
|
}
|
||||||
@ -160,7 +145,6 @@ func (cm *ChannelManager) Join(client *Client, name string, key string, isSajoin
|
|||||||
return err, ""
|
return err, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.EnsureLoaded()
|
|
||||||
err, forward = channel.Join(client, key, isSajoin || newChannel, rb)
|
err, forward = channel.Join(client, key, isSajoin || newChannel, rb)
|
||||||
|
|
||||||
cm.maybeCleanup(channel, true)
|
cm.maybeCleanup(channel, true)
|
||||||
@ -222,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
|
||||||
}
|
}
|
||||||
@ -252,13 +240,6 @@ func (cm *ChannelManager) SetRegistered(channelName string, account string) (err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// transfer the skeleton from chansSkeletons to registeredSkeletons
|
|
||||||
skeleton := entry.skeleton
|
|
||||||
delete(cm.chansSkeletons, skeleton)
|
|
||||||
entry.skeleton = ""
|
|
||||||
cm.chans[cfname] = entry
|
|
||||||
cm.registeredChannels.Add(cfname)
|
|
||||||
cm.registeredSkeletons.Add(skeleton)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,17 +249,13 @@ func (cm *ChannelManager) SetUnregistered(channelName string, account string) (e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := cm.server.channelRegistry.LoadChannel(cfname)
|
var uuid utils.UUID
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if info.Founder != account {
|
|
||||||
return errChannelNotOwnedByAccount
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = cm.server.channelRegistry.Delete(info)
|
if delErr := cm.server.dstore.Delete(datastore.TableChannels, uuid); delErr != nil {
|
||||||
|
cm.server.logger.Error("datastore", "couldn't delete channel registration", cfname, delErr.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -286,15 +263,11 @@ func (cm *ChannelManager) SetUnregistered(channelName string, account string) (e
|
|||||||
defer cm.Unlock()
|
defer cm.Unlock()
|
||||||
entry := cm.chans[cfname]
|
entry := cm.chans[cfname]
|
||||||
if entry != nil {
|
if entry != nil {
|
||||||
entry.channel.SetUnregistered(account)
|
if entry.channel.Founder() != account {
|
||||||
delete(cm.registeredChannels, cfname)
|
return errChannelNotOwnedByAccount
|
||||||
// transfer the skeleton from registeredSkeletons to chansSkeletons
|
|
||||||
if skel, err := Skeleton(entry.channel.Name()); err == nil {
|
|
||||||
delete(cm.registeredSkeletons, skel)
|
|
||||||
cm.chansSkeletons.Add(skel)
|
|
||||||
entry.skeleton = skel
|
|
||||||
cm.chans[cfname] = entry
|
|
||||||
}
|
}
|
||||||
|
uuid = entry.channel.UUID()
|
||||||
|
entry.channel.SetUnregistered(account) // changes the UUID
|
||||||
// #1619: if the channel has 0 members and was only being retained
|
// #1619: if the channel has 0 members and was only being retained
|
||||||
// because it was registered, clean it up:
|
// because it was registered, clean it up:
|
||||||
cm.maybeCleanupInternal(cfname, entry, false)
|
cm.maybeCleanupInternal(cfname, entry, false)
|
||||||
@ -322,12 +295,11 @@ func (cm *ChannelManager) Rename(name string, newName string) (err error) {
|
|||||||
var info RegisteredChannel
|
var info RegisteredChannel
|
||||||
defer func() {
|
defer func() {
|
||||||
if channel != nil && info.Founder != "" {
|
if channel != nil && info.Founder != "" {
|
||||||
channel.Store(IncludeAllAttrs)
|
channel.MarkDirty(IncludeAllAttrs)
|
||||||
if oldCfname != newCfname {
|
}
|
||||||
// we just flushed the channel under its new name, therefore this delete
|
// always-on clients need to update their saved channel memberships
|
||||||
// cannot be overwritten by a write to the old name:
|
for _, member := range channel.Members() {
|
||||||
cm.server.channelRegistry.Delete(info)
|
member.markDirty(IncludeChannels)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -335,11 +307,11 @@ func (cm *ChannelManager) Rename(name string, newName string) (err error) {
|
|||||||
defer cm.Unlock()
|
defer cm.Unlock()
|
||||||
|
|
||||||
entry := cm.chans[oldCfname]
|
entry := cm.chans[oldCfname]
|
||||||
if entry == nil || !entry.channel.IsLoaded() {
|
if entry == nil {
|
||||||
return errNoSuchChannel
|
return errNoSuchChannel
|
||||||
}
|
}
|
||||||
channel = entry.channel
|
channel = entry.channel
|
||||||
info = channel.ExportRegistration(IncludeInitial)
|
info = channel.ExportRegistration()
|
||||||
registered := info.Founder != ""
|
registered := info.Founder != ""
|
||||||
|
|
||||||
oldSkeleton, err := Skeleton(info.Name)
|
oldSkeleton, err := Skeleton(info.Name)
|
||||||
@ -348,13 +320,13 @@ func (cm *ChannelManager) Rename(name string, newName string) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if newCfname != oldCfname {
|
if newCfname != oldCfname {
|
||||||
if cm.chans[newCfname] != nil || cm.registeredChannels.Has(newCfname) {
|
if cm.chans[newCfname] != nil {
|
||||||
return errChannelNameInUse
|
return errChannelNameInUse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if oldSkeleton != newSkeleton {
|
if oldSkeleton != newSkeleton {
|
||||||
if cm.chansSkeletons.Has(newSkeleton) || cm.registeredSkeletons.Has(newSkeleton) {
|
if cm.chansSkeletons.Has(newSkeleton) {
|
||||||
return errConfusableIdentifier
|
return errConfusableIdentifier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -364,15 +336,8 @@ func (cm *ChannelManager) Rename(name string, newName string) (err error) {
|
|||||||
entry.skeleton = newSkeleton
|
entry.skeleton = newSkeleton
|
||||||
}
|
}
|
||||||
cm.chans[newCfname] = entry
|
cm.chans[newCfname] = entry
|
||||||
if registered {
|
delete(cm.chansSkeletons, oldSkeleton)
|
||||||
delete(cm.registeredChannels, oldCfname)
|
cm.chansSkeletons.Add(newSkeleton)
|
||||||
cm.registeredChannels.Add(newCfname)
|
|
||||||
delete(cm.registeredSkeletons, oldSkeleton)
|
|
||||||
cm.registeredSkeletons.Add(newSkeleton)
|
|
||||||
} else {
|
|
||||||
delete(cm.chansSkeletons, oldSkeleton)
|
|
||||||
cm.chansSkeletons.Add(newSkeleton)
|
|
||||||
}
|
|
||||||
entry.channel.Rename(newName, newCfname)
|
entry.channel.Rename(newName, newCfname)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -390,7 +355,18 @@ func (cm *ChannelManager) Channels() (result []*Channel) {
|
|||||||
defer cm.RUnlock()
|
defer cm.RUnlock()
|
||||||
result = make([]*Channel, 0, len(cm.chans))
|
result = make([]*Channel, 0, len(cm.chans))
|
||||||
for _, entry := range cm.chans {
|
for _, entry := range cm.chans {
|
||||||
if entry.channel.IsLoaded() {
|
result = append(result, entry.channel)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListableChannels returns a slice of all non-purged channels.
|
||||||
|
func (cm *ChannelManager) ListableChannels() (result []*Channel) {
|
||||||
|
cm.RLock()
|
||||||
|
defer cm.RUnlock()
|
||||||
|
result = make([]*Channel, 0, len(cm.chans))
|
||||||
|
for cfname, entry := range cm.chans {
|
||||||
|
if _, ok := cm.purgedChannels[cfname]; !ok {
|
||||||
result = append(result, entry.channel)
|
result = append(result, entry.channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -403,29 +379,46 @@ func (cm *ChannelManager) Purge(chname string, record ChannelPurgeRecord) (err e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errInvalidChannelName
|
return errInvalidChannelName
|
||||||
}
|
}
|
||||||
skel, err := Skeleton(chname)
|
|
||||||
if err != nil {
|
|
||||||
return errInvalidChannelName
|
|
||||||
}
|
|
||||||
|
|
||||||
cm.Lock()
|
record.NameCasefolded = chname
|
||||||
cm.purgedChannels.Add(chname)
|
record.UUID = utils.GenerateUUIDv4()
|
||||||
entry := cm.chans[chname]
|
|
||||||
if entry != nil {
|
channel, err := func() (channel *Channel, err error) {
|
||||||
delete(cm.chans, chname)
|
cm.Lock()
|
||||||
if entry.channel.Founder() != "" {
|
defer cm.Unlock()
|
||||||
delete(cm.registeredSkeletons, skel)
|
|
||||||
} else {
|
if _, ok := cm.purgedChannels[chname]; ok {
|
||||||
delete(cm.chansSkeletons, skel)
|
return nil, errChannelPurgedAlready
|
||||||
}
|
}
|
||||||
}
|
|
||||||
cm.Unlock()
|
|
||||||
|
|
||||||
cm.server.channelRegistry.PurgeChannel(chname, record)
|
entry := cm.chans[chname]
|
||||||
if entry != nil {
|
// atomically prevent anyone from rejoining
|
||||||
entry.channel.Purge("")
|
cm.purgedChannels[chname] = record
|
||||||
|
if entry != nil {
|
||||||
|
channel = entry.channel
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
if channel != nil {
|
||||||
|
// actually kick everyone off the channel
|
||||||
|
channel.Purge("")
|
||||||
|
}
|
||||||
|
|
||||||
|
var purgeBytes []byte
|
||||||
|
if purgeBytes, err = record.Serialize(); err != nil {
|
||||||
|
cm.server.logger.Error("internal", "couldn't serialize purge record", channel.Name(), err.Error())
|
||||||
|
}
|
||||||
|
// TODO we need a better story about error handling for later
|
||||||
|
if err = cm.server.dstore.Set(datastore.TableChannelPurges, record.UUID, purgeBytes, time.Time{}); err != nil {
|
||||||
|
cm.server.logger.Error("datastore", "couldn't store purge record", chname, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsPurged queries whether a channel is purged.
|
// IsPurged queries whether a channel is purged.
|
||||||
@ -436,7 +429,7 @@ func (cm *ChannelManager) IsPurged(chname string) (result bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cm.RLock()
|
cm.RLock()
|
||||||
result = cm.purgedChannels.Has(chname)
|
_, result = cm.purgedChannels[chname]
|
||||||
cm.RUnlock()
|
cm.RUnlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -449,14 +442,16 @@ func (cm *ChannelManager) Unpurge(chname string) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cm.Lock()
|
cm.Lock()
|
||||||
found := cm.purgedChannels.Has(chname)
|
record, found := cm.purgedChannels[chname]
|
||||||
delete(cm.purgedChannels, chname)
|
delete(cm.purgedChannels, chname)
|
||||||
cm.Unlock()
|
cm.Unlock()
|
||||||
|
|
||||||
cm.server.channelRegistry.UnpurgeChannel(chname)
|
|
||||||
if !found {
|
if !found {
|
||||||
return errNoSuchChannel
|
return errNoSuchChannel
|
||||||
}
|
}
|
||||||
|
if err := cm.server.dstore.Delete(datastore.TableChannelPurges, record.UUID); err != nil {
|
||||||
|
cm.server.logger.Error("datastore", "couldn't delete purge record", chname, err.Error())
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -475,8 +470,46 @@ func (cm *ChannelManager) UnfoldName(cfname string) (result string) {
|
|||||||
cm.RLock()
|
cm.RLock()
|
||||||
entry := cm.chans[cfname]
|
entry := cm.chans[cfname]
|
||||||
cm.RUnlock()
|
cm.RUnlock()
|
||||||
if entry != nil && entry.channel.IsLoaded() {
|
if entry != nil {
|
||||||
return entry.channel.Name()
|
return entry.channel.Name()
|
||||||
}
|
}
|
||||||
return cfname
|
return cfname
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cm *ChannelManager) LoadPurgeRecord(cfchname string) (record ChannelPurgeRecord, err error) {
|
||||||
|
cm.RLock()
|
||||||
|
defer cm.RUnlock()
|
||||||
|
|
||||||
|
if record, ok := cm.purgedChannels[cfchname]; ok {
|
||||||
|
return record, nil
|
||||||
|
} else {
|
||||||
|
return record, errNoSuchChannel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cm *ChannelManager) ChannelsForAccount(account string) (channels []string) {
|
||||||
|
cm.RLock()
|
||||||
|
defer cm.RUnlock()
|
||||||
|
|
||||||
|
for cfname, entry := range cm.chans {
|
||||||
|
if entry.channel.Founder() == account {
|
||||||
|
channels = append(channels, cfname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllChannels returns the uncasefolded names of all registered channels.
|
||||||
|
func (cm *ChannelManager) AllRegisteredChannels() (result []string) {
|
||||||
|
cm.RLock()
|
||||||
|
defer cm.RUnlock()
|
||||||
|
|
||||||
|
for cfname, entry := range cm.chans {
|
||||||
|
if entry.channel.Founder() != "" {
|
||||||
|
result = append(result, cfname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|||||||
@ -5,13 +5,8 @@ package irc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tidwall/buntdb"
|
|
||||||
|
|
||||||
"github.com/ergochat/ergo/irc/modes"
|
"github.com/ergochat/ergo/irc/modes"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
)
|
)
|
||||||
@ -19,48 +14,6 @@ import (
|
|||||||
// this is exclusively the *persistence* layer for channel registration;
|
// this is exclusively the *persistence* layer for channel registration;
|
||||||
// channel creation/tracking/destruction is in channelmanager.go
|
// channel creation/tracking/destruction is in channelmanager.go
|
||||||
|
|
||||||
const (
|
|
||||||
keyChannelExists = "channel.exists %s"
|
|
||||||
keyChannelName = "channel.name %s" // stores the 'preferred name' of the channel, not casemapped
|
|
||||||
keyChannelRegTime = "channel.registered.time %s"
|
|
||||||
keyChannelFounder = "channel.founder %s"
|
|
||||||
keyChannelTopic = "channel.topic %s"
|
|
||||||
keyChannelTopicSetBy = "channel.topic.setby %s"
|
|
||||||
keyChannelTopicSetTime = "channel.topic.settime %s"
|
|
||||||
keyChannelBanlist = "channel.banlist %s"
|
|
||||||
keyChannelExceptlist = "channel.exceptlist %s"
|
|
||||||
keyChannelInvitelist = "channel.invitelist %s"
|
|
||||||
keyChannelPassword = "channel.key %s"
|
|
||||||
keyChannelModes = "channel.modes %s"
|
|
||||||
keyChannelAccountToUMode = "channel.accounttoumode %s"
|
|
||||||
keyChannelUserLimit = "channel.userlimit %s"
|
|
||||||
keyChannelSettings = "channel.settings %s"
|
|
||||||
keyChannelForward = "channel.forward %s"
|
|
||||||
|
|
||||||
keyChannelPurged = "channel.purged %s"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
channelKeyStrings = []string{
|
|
||||||
keyChannelExists,
|
|
||||||
keyChannelName,
|
|
||||||
keyChannelRegTime,
|
|
||||||
keyChannelFounder,
|
|
||||||
keyChannelTopic,
|
|
||||||
keyChannelTopicSetBy,
|
|
||||||
keyChannelTopicSetTime,
|
|
||||||
keyChannelBanlist,
|
|
||||||
keyChannelExceptlist,
|
|
||||||
keyChannelInvitelist,
|
|
||||||
keyChannelPassword,
|
|
||||||
keyChannelModes,
|
|
||||||
keyChannelAccountToUMode,
|
|
||||||
keyChannelUserLimit,
|
|
||||||
keyChannelSettings,
|
|
||||||
keyChannelForward,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// these are bit flags indicating what part of the channel status is "dirty"
|
// these are bit flags indicating what part of the channel status is "dirty"
|
||||||
// and needs to be read from memory and written to the db
|
// and needs to be read from memory and written to the db
|
||||||
const (
|
const (
|
||||||
@ -80,8 +33,8 @@ const (
|
|||||||
type RegisteredChannel struct {
|
type RegisteredChannel struct {
|
||||||
// Name of the channel.
|
// Name of the channel.
|
||||||
Name string
|
Name string
|
||||||
// Casefolded name of the channel.
|
// UUID for the datastore.
|
||||||
NameCasefolded string
|
UUID utils.UUID
|
||||||
// RegisteredAt represents the time that the channel was registered.
|
// RegisteredAt represents the time that the channel was registered.
|
||||||
RegisteredAt time.Time
|
RegisteredAt time.Time
|
||||||
// Founder indicates the founder of the channel.
|
// Founder indicates the founder of the channel.
|
||||||
@ -110,324 +63,30 @@ 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) {
|
||||||
|
return json.Marshal(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegisteredChannel) Deserialize(b []byte) (err error) {
|
||||||
|
return json.Unmarshal(b, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelPurgeRecord struct {
|
type ChannelPurgeRecord struct {
|
||||||
Oper string
|
NameCasefolded string `json:"Name"`
|
||||||
PurgedAt time.Time
|
UUID utils.UUID
|
||||||
Reason string
|
Oper string
|
||||||
|
PurgedAt time.Time
|
||||||
|
Reason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelRegistry manages registered channels.
|
func (c *ChannelPurgeRecord) Serialize() ([]byte, error) {
|
||||||
type ChannelRegistry struct {
|
return json.Marshal(c)
|
||||||
server *Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChannelRegistry returns a new ChannelRegistry.
|
func (c *ChannelPurgeRecord) Deserialize(b []byte) error {
|
||||||
func (reg *ChannelRegistry) Initialize(server *Server) {
|
return json.Unmarshal(b, c)
|
||||||
reg.server = server
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllChannels returns the uncasefolded names of all registered channels.
|
|
||||||
func (reg *ChannelRegistry) AllChannels() (result []string) {
|
|
||||||
prefix := fmt.Sprintf(keyChannelName, "")
|
|
||||||
reg.server.store.View(func(tx *buntdb.Tx) error {
|
|
||||||
return tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool {
|
|
||||||
if !strings.HasPrefix(key, prefix) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
result = append(result, value)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// PurgedChannels returns the set of all casefolded channel names that have been purged
|
|
||||||
func (reg *ChannelRegistry) PurgedChannels() (result utils.HashSet[string]) {
|
|
||||||
result = make(utils.HashSet[string])
|
|
||||||
|
|
||||||
prefix := fmt.Sprintf(keyChannelPurged, "")
|
|
||||||
reg.server.store.View(func(tx *buntdb.Tx) error {
|
|
||||||
return tx.AscendGreaterOrEqual("", prefix, func(key, value string) bool {
|
|
||||||
if !strings.HasPrefix(key, prefix) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
channel := strings.TrimPrefix(key, prefix)
|
|
||||||
result.Add(channel)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// StoreChannel obtains a consistent view of a channel, then persists it to the store.
|
|
||||||
func (reg *ChannelRegistry) StoreChannel(info RegisteredChannel, includeFlags uint) (err error) {
|
|
||||||
if !reg.server.ChannelRegistrationEnabled() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Founder == "" {
|
|
||||||
// sanity check, don't try to store an unregistered channel
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reg.server.store.Update(func(tx *buntdb.Tx) error {
|
|
||||||
reg.saveChannel(tx, info, includeFlags)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadChannel loads a channel from the store.
|
|
||||||
func (reg *ChannelRegistry) LoadChannel(nameCasefolded string) (info RegisteredChannel, err error) {
|
|
||||||
if !reg.server.ChannelRegistrationEnabled() {
|
|
||||||
err = errFeatureDisabled
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
channelKey := nameCasefolded
|
|
||||||
// nice to have: do all JSON (de)serialization outside of the buntdb transaction
|
|
||||||
err = reg.server.store.View(func(tx *buntdb.Tx) error {
|
|
||||||
_, dberr := tx.Get(fmt.Sprintf(keyChannelExists, channelKey))
|
|
||||||
if dberr == buntdb.ErrNotFound {
|
|
||||||
// chan does not already exist, return
|
|
||||||
return errNoSuchChannel
|
|
||||||
}
|
|
||||||
|
|
||||||
// channel exists, load it
|
|
||||||
name, _ := tx.Get(fmt.Sprintf(keyChannelName, channelKey))
|
|
||||||
regTime, _ := tx.Get(fmt.Sprintf(keyChannelRegTime, channelKey))
|
|
||||||
regTimeInt, _ := strconv.ParseInt(regTime, 10, 64)
|
|
||||||
founder, _ := tx.Get(fmt.Sprintf(keyChannelFounder, channelKey))
|
|
||||||
topic, _ := tx.Get(fmt.Sprintf(keyChannelTopic, channelKey))
|
|
||||||
topicSetBy, _ := tx.Get(fmt.Sprintf(keyChannelTopicSetBy, channelKey))
|
|
||||||
var topicSetTime time.Time
|
|
||||||
topicSetTimeStr, _ := tx.Get(fmt.Sprintf(keyChannelTopicSetTime, channelKey))
|
|
||||||
if topicSetTimeInt, topicSetTimeErr := strconv.ParseInt(topicSetTimeStr, 10, 64); topicSetTimeErr == nil {
|
|
||||||
topicSetTime = time.Unix(0, topicSetTimeInt).UTC()
|
|
||||||
}
|
|
||||||
password, _ := tx.Get(fmt.Sprintf(keyChannelPassword, channelKey))
|
|
||||||
modeString, _ := tx.Get(fmt.Sprintf(keyChannelModes, channelKey))
|
|
||||||
userLimitString, _ := tx.Get(fmt.Sprintf(keyChannelUserLimit, channelKey))
|
|
||||||
forward, _ := tx.Get(fmt.Sprintf(keyChannelForward, channelKey))
|
|
||||||
banlistString, _ := tx.Get(fmt.Sprintf(keyChannelBanlist, channelKey))
|
|
||||||
exceptlistString, _ := tx.Get(fmt.Sprintf(keyChannelExceptlist, channelKey))
|
|
||||||
invitelistString, _ := tx.Get(fmt.Sprintf(keyChannelInvitelist, channelKey))
|
|
||||||
accountToUModeString, _ := tx.Get(fmt.Sprintf(keyChannelAccountToUMode, channelKey))
|
|
||||||
settingsString, _ := tx.Get(fmt.Sprintf(keyChannelSettings, channelKey))
|
|
||||||
|
|
||||||
modeSlice := make([]modes.Mode, len(modeString))
|
|
||||||
for i, mode := range modeString {
|
|
||||||
modeSlice[i] = modes.Mode(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
userLimit, _ := strconv.Atoi(userLimitString)
|
|
||||||
|
|
||||||
var banlist map[string]MaskInfo
|
|
||||||
_ = json.Unmarshal([]byte(banlistString), &banlist)
|
|
||||||
var exceptlist map[string]MaskInfo
|
|
||||||
_ = json.Unmarshal([]byte(exceptlistString), &exceptlist)
|
|
||||||
var invitelist map[string]MaskInfo
|
|
||||||
_ = json.Unmarshal([]byte(invitelistString), &invitelist)
|
|
||||||
accountToUMode := make(map[string]modes.Mode)
|
|
||||||
_ = json.Unmarshal([]byte(accountToUModeString), &accountToUMode)
|
|
||||||
|
|
||||||
var settings ChannelSettings
|
|
||||||
_ = json.Unmarshal([]byte(settingsString), &settings)
|
|
||||||
|
|
||||||
info = RegisteredChannel{
|
|
||||||
Name: name,
|
|
||||||
NameCasefolded: nameCasefolded,
|
|
||||||
RegisteredAt: time.Unix(0, regTimeInt).UTC(),
|
|
||||||
Founder: founder,
|
|
||||||
Topic: topic,
|
|
||||||
TopicSetBy: topicSetBy,
|
|
||||||
TopicSetTime: topicSetTime,
|
|
||||||
Key: password,
|
|
||||||
Modes: modeSlice,
|
|
||||||
Bans: banlist,
|
|
||||||
Excepts: exceptlist,
|
|
||||||
Invites: invitelist,
|
|
||||||
AccountToUMode: accountToUMode,
|
|
||||||
UserLimit: int(userLimit),
|
|
||||||
Settings: settings,
|
|
||||||
Forward: forward,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete deletes a channel corresponding to `info`. If no such channel
|
|
||||||
// is present in the database, no error is returned.
|
|
||||||
func (reg *ChannelRegistry) Delete(info RegisteredChannel) (err error) {
|
|
||||||
if !reg.server.ChannelRegistrationEnabled() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reg.server.store.Update(func(tx *buntdb.Tx) error {
|
|
||||||
reg.deleteChannel(tx, info.NameCasefolded, info)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete a channel, unless it was overwritten by another registration of the same channel
|
|
||||||
func (reg *ChannelRegistry) deleteChannel(tx *buntdb.Tx, key string, info RegisteredChannel) {
|
|
||||||
_, err := tx.Get(fmt.Sprintf(keyChannelExists, key))
|
|
||||||
if err == nil {
|
|
||||||
regTime, _ := tx.Get(fmt.Sprintf(keyChannelRegTime, key))
|
|
||||||
regTimeInt, _ := strconv.ParseInt(regTime, 10, 64)
|
|
||||||
registeredAt := time.Unix(0, regTimeInt).UTC()
|
|
||||||
founder, _ := tx.Get(fmt.Sprintf(keyChannelFounder, key))
|
|
||||||
|
|
||||||
// to see if we're deleting the right channel, confirm the founder and the registration time
|
|
||||||
if founder == info.Founder && registeredAt.Equal(info.RegisteredAt) {
|
|
||||||
for _, keyFmt := range channelKeyStrings {
|
|
||||||
tx.Delete(fmt.Sprintf(keyFmt, key))
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove this channel from the client's list of registered channels
|
|
||||||
channelsKey := fmt.Sprintf(keyAccountChannels, info.Founder)
|
|
||||||
channelsStr, err := tx.Get(channelsKey)
|
|
||||||
if err == buntdb.ErrNotFound {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
registeredChannels := unmarshalRegisteredChannels(channelsStr)
|
|
||||||
var nowRegisteredChannels []string
|
|
||||||
for _, channel := range registeredChannels {
|
|
||||||
if channel != key {
|
|
||||||
nowRegisteredChannels = append(nowRegisteredChannels, channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tx.Set(channelsKey, strings.Join(nowRegisteredChannels, ","), nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reg *ChannelRegistry) updateAccountToChannelMapping(tx *buntdb.Tx, channelInfo RegisteredChannel) {
|
|
||||||
channelKey := channelInfo.NameCasefolded
|
|
||||||
chanFounderKey := fmt.Sprintf(keyChannelFounder, channelKey)
|
|
||||||
founder, existsErr := tx.Get(chanFounderKey)
|
|
||||||
if existsErr == buntdb.ErrNotFound || founder != channelInfo.Founder {
|
|
||||||
// add to new founder's list
|
|
||||||
accountChannelsKey := fmt.Sprintf(keyAccountChannels, channelInfo.Founder)
|
|
||||||
alreadyChannels, _ := tx.Get(accountChannelsKey)
|
|
||||||
newChannels := channelKey // this is the casefolded channel name
|
|
||||||
if alreadyChannels != "" {
|
|
||||||
newChannels = fmt.Sprintf("%s,%s", alreadyChannels, newChannels)
|
|
||||||
}
|
|
||||||
tx.Set(accountChannelsKey, newChannels, nil)
|
|
||||||
}
|
|
||||||
if existsErr == nil && founder != channelInfo.Founder {
|
|
||||||
// remove from old founder's list
|
|
||||||
accountChannelsKey := fmt.Sprintf(keyAccountChannels, founder)
|
|
||||||
alreadyChannelsRaw, _ := tx.Get(accountChannelsKey)
|
|
||||||
var newChannels []string
|
|
||||||
if alreadyChannelsRaw != "" {
|
|
||||||
for _, chname := range strings.Split(alreadyChannelsRaw, ",") {
|
|
||||||
if chname != channelInfo.NameCasefolded {
|
|
||||||
newChannels = append(newChannels, chname)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tx.Set(accountChannelsKey, strings.Join(newChannels, ","), nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// saveChannel saves a channel to the store.
|
|
||||||
func (reg *ChannelRegistry) saveChannel(tx *buntdb.Tx, channelInfo RegisteredChannel, includeFlags uint) {
|
|
||||||
channelKey := channelInfo.NameCasefolded
|
|
||||||
// maintain the mapping of account -> registered channels
|
|
||||||
reg.updateAccountToChannelMapping(tx, channelInfo)
|
|
||||||
|
|
||||||
if includeFlags&IncludeInitial != 0 {
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelExists, channelKey), "1", nil)
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelName, channelKey), channelInfo.Name, nil)
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelRegTime, channelKey), strconv.FormatInt(channelInfo.RegisteredAt.UnixNano(), 10), nil)
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelFounder, channelKey), channelInfo.Founder, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if includeFlags&IncludeTopic != 0 {
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelTopic, channelKey), channelInfo.Topic, nil)
|
|
||||||
var topicSetTimeStr string
|
|
||||||
if !channelInfo.TopicSetTime.IsZero() {
|
|
||||||
topicSetTimeStr = strconv.FormatInt(channelInfo.TopicSetTime.UnixNano(), 10)
|
|
||||||
}
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelTopicSetTime, channelKey), topicSetTimeStr, nil)
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelTopicSetBy, channelKey), channelInfo.TopicSetBy, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if includeFlags&IncludeModes != 0 {
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelPassword, channelKey), channelInfo.Key, nil)
|
|
||||||
modeString := modes.Modes(channelInfo.Modes).String()
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelModes, channelKey), modeString, nil)
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelUserLimit, channelKey), strconv.Itoa(channelInfo.UserLimit), nil)
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelForward, channelKey), channelInfo.Forward, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if includeFlags&IncludeLists != 0 {
|
|
||||||
banlistString, _ := json.Marshal(channelInfo.Bans)
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelBanlist, channelKey), string(banlistString), nil)
|
|
||||||
exceptlistString, _ := json.Marshal(channelInfo.Excepts)
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelExceptlist, channelKey), string(exceptlistString), nil)
|
|
||||||
invitelistString, _ := json.Marshal(channelInfo.Invites)
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelInvitelist, channelKey), string(invitelistString), nil)
|
|
||||||
accountToUModeString, _ := json.Marshal(channelInfo.AccountToUMode)
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelAccountToUMode, channelKey), string(accountToUModeString), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if includeFlags&IncludeSettings != 0 {
|
|
||||||
settingsString, _ := json.Marshal(channelInfo.Settings)
|
|
||||||
tx.Set(fmt.Sprintf(keyChannelSettings, channelKey), string(settingsString), nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PurgeChannel records a channel purge.
|
|
||||||
func (reg *ChannelRegistry) PurgeChannel(chname string, record ChannelPurgeRecord) (err error) {
|
|
||||||
serialized, err := json.Marshal(record)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
serializedStr := string(serialized)
|
|
||||||
key := fmt.Sprintf(keyChannelPurged, chname)
|
|
||||||
|
|
||||||
return reg.server.store.Update(func(tx *buntdb.Tx) error {
|
|
||||||
tx.Set(key, serializedStr, nil)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadPurgeRecord retrieves information about whether and how a channel was purged.
|
|
||||||
func (reg *ChannelRegistry) LoadPurgeRecord(chname string) (record ChannelPurgeRecord, err error) {
|
|
||||||
var rawRecord string
|
|
||||||
key := fmt.Sprintf(keyChannelPurged, chname)
|
|
||||||
reg.server.store.View(func(tx *buntdb.Tx) error {
|
|
||||||
rawRecord, _ = tx.Get(key)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if rawRecord == "" {
|
|
||||||
err = errNoSuchChannel
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = json.Unmarshal([]byte(rawRecord), &record)
|
|
||||||
if err != nil {
|
|
||||||
reg.server.logger.Error("internal", "corrupt purge record", chname, err.Error())
|
|
||||||
err = errNoSuchChannel
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnpurgeChannel deletes the record of a channel purge.
|
|
||||||
func (reg *ChannelRegistry) UnpurgeChannel(chname string) (err error) {
|
|
||||||
key := fmt.Sprintf(keyChannelPurged, chname)
|
|
||||||
return reg.server.store.Update(func(tx *buntdb.Tx) error {
|
|
||||||
tx.Delete(key)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ package irc
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -218,7 +219,7 @@ func csAmodeHandler(service *ircService, server *Server, client *Client, command
|
|||||||
// check for anything valid as a channel mode change that is not valid
|
// check for anything valid as a channel mode change that is not valid
|
||||||
// as an AMODE change
|
// as an AMODE change
|
||||||
for _, modeChange := range modeChanges {
|
for _, modeChange := range modeChanges {
|
||||||
if !utils.SliceContains(modes.ChannelUserModes, modeChange.Mode) {
|
if !slices.Contains(modes.ChannelUserModes, modeChange.Mode) {
|
||||||
invalid = true
|
invalid = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -459,7 +460,7 @@ func csRegisterHandler(service *ircService, server *Server, client *Client, comm
|
|||||||
// check whether a client has already registered too many channels
|
// check whether a client has already registered too many channels
|
||||||
func checkChanLimit(service *ircService, client *Client, rb *ResponseBuffer) (ok bool) {
|
func checkChanLimit(service *ircService, client *Client, rb *ResponseBuffer) (ok bool) {
|
||||||
account := client.Account()
|
account := client.Account()
|
||||||
channelsAlreadyRegistered := client.server.accounts.ChannelsForAccount(account)
|
channelsAlreadyRegistered := client.server.channels.ChannelsForAccount(account)
|
||||||
ok = len(channelsAlreadyRegistered) < client.server.Config().Channels.Registration.MaxChannelsPerAccount || client.HasRoleCapabs("chanreg")
|
ok = len(channelsAlreadyRegistered) < client.server.Config().Channels.Registration.MaxChannelsPerAccount || client.HasRoleCapabs("chanreg")
|
||||||
if !ok {
|
if !ok {
|
||||||
service.Notice(rb, client.t("You have already registered the maximum number of channels; try dropping some with /CS UNREGISTER"))
|
service.Notice(rb, client.t("You have already registered the maximum number of channels; try dropping some with /CS UNREGISTER"))
|
||||||
@ -496,8 +497,8 @@ func csUnregisterHandler(service *ircService, server *Server, client *Client, co
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
info := channel.ExportRegistration(0)
|
info := channel.exportSummary()
|
||||||
channelKey := info.NameCasefolded
|
channelKey := channel.NameCasefolded()
|
||||||
if !csPrivsCheck(service, info, client, rb) {
|
if !csPrivsCheck(service, info, client, rb) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -519,7 +520,7 @@ func csClearHandler(service *ircService, server *Server, client *Client, command
|
|||||||
service.Notice(rb, client.t("Channel does not exist"))
|
service.Notice(rb, client.t("Channel does not exist"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !csPrivsCheck(service, channel.ExportRegistration(0), client, rb) {
|
if !csPrivsCheck(service, channel.exportSummary(), client, rb) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -550,7 +551,7 @@ func csTransferHandler(service *ircService, server *Server, client *Client, comm
|
|||||||
service.Notice(rb, client.t("Channel does not exist"))
|
service.Notice(rb, client.t("Channel does not exist"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
regInfo := channel.ExportRegistration(0)
|
regInfo := channel.exportSummary()
|
||||||
chname = regInfo.Name
|
chname = regInfo.Name
|
||||||
account := client.Account()
|
account := client.Account()
|
||||||
isFounder := account != "" && account == regInfo.Founder
|
isFounder := account != "" && account == regInfo.Founder
|
||||||
@ -729,11 +730,6 @@ func csPurgeListHandler(service *ircService, client *Client, rb *ResponseBuffer)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func csListHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
func csListHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||||
if !client.HasRoleCapabs("chanreg") {
|
|
||||||
service.Notice(rb, client.t("Insufficient privileges"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchRegex *regexp.Regexp
|
var searchRegex *regexp.Regexp
|
||||||
if len(params) > 0 {
|
if len(params) > 0 {
|
||||||
var err error
|
var err error
|
||||||
@ -746,7 +742,7 @@ func csListHandler(service *ircService, server *Server, client *Client, command
|
|||||||
|
|
||||||
service.Notice(rb, ircfmt.Unescape(client.t("*** $bChanServ LIST$b ***")))
|
service.Notice(rb, ircfmt.Unescape(client.t("*** $bChanServ LIST$b ***")))
|
||||||
|
|
||||||
channels := server.channelRegistry.AllChannels()
|
channels := server.channels.AllRegisteredChannels()
|
||||||
for _, channel := range channels {
|
for _, channel := range channels {
|
||||||
if searchRegex == nil || searchRegex.MatchString(channel) {
|
if searchRegex == nil || searchRegex.MatchString(channel) {
|
||||||
service.Notice(rb, fmt.Sprintf(" %s", channel))
|
service.Notice(rb, fmt.Sprintf(" %s", channel))
|
||||||
@ -771,7 +767,7 @@ func csInfoHandler(service *ircService, server *Server, client *Client, command
|
|||||||
|
|
||||||
// purge status
|
// purge status
|
||||||
if client.HasRoleCapabs("chanreg") {
|
if client.HasRoleCapabs("chanreg") {
|
||||||
purgeRecord, err := server.channelRegistry.LoadPurgeRecord(chname)
|
purgeRecord, err := server.channels.LoadPurgeRecord(chname)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname))
|
service.Notice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname))
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Purged by operator: %s"), purgeRecord.Oper))
|
service.Notice(rb, fmt.Sprintf(client.t("Purged by operator: %s"), purgeRecord.Oper))
|
||||||
@ -789,13 +785,7 @@ func csInfoHandler(service *ircService, server *Server, client *Client, command
|
|||||||
var chinfo RegisteredChannel
|
var chinfo RegisteredChannel
|
||||||
channel := server.channels.Get(params[0])
|
channel := server.channels.Get(params[0])
|
||||||
if channel != nil {
|
if channel != nil {
|
||||||
chinfo = channel.ExportRegistration(0)
|
chinfo = channel.exportSummary()
|
||||||
} else {
|
|
||||||
chinfo, err = server.channelRegistry.LoadChannel(chname)
|
|
||||||
if err != nil && !(err == errNoSuchChannel || err == errFeatureDisabled) {
|
|
||||||
service.Notice(rb, client.t("An error occurred"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// channel exists but is unregistered, or doesn't exist:
|
// channel exists but is unregistered, or doesn't exist:
|
||||||
@ -835,12 +825,12 @@ func csGetHandler(service *ircService, server *Server, client *Client, command s
|
|||||||
service.Notice(rb, client.t("No such channel"))
|
service.Notice(rb, client.t("No such channel"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
info := channel.ExportRegistration(IncludeSettings)
|
info := channel.exportSummary()
|
||||||
if !csPrivsCheck(service, info, client, rb) {
|
if !csPrivsCheck(service, info, client, rb) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
displayChannelSetting(service, setting, info.Settings, client, rb)
|
displayChannelSetting(service, setting, channel.Settings(), client, rb)
|
||||||
}
|
}
|
||||||
|
|
||||||
func csSetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
func csSetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||||
@ -850,12 +840,12 @@ func csSetHandler(service *ircService, server *Server, client *Client, command s
|
|||||||
service.Notice(rb, client.t("No such channel"))
|
service.Notice(rb, client.t("No such channel"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
info := channel.ExportRegistration(IncludeSettings)
|
info := channel.exportSummary()
|
||||||
settings := info.Settings
|
|
||||||
if !csPrivsCheck(service, info, client, rb) {
|
if !csPrivsCheck(service, info, client, rb) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
settings := channel.Settings()
|
||||||
var err error
|
var err error
|
||||||
switch strings.ToLower(setting) {
|
switch strings.ToLower(setting) {
|
||||||
case "history":
|
case "history":
|
||||||
|
|||||||
591
irc/client.go
591
irc/client.go
@ -6,8 +6,10 @@
|
|||||||
package irc
|
package irc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -20,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"
|
||||||
@ -27,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 (
|
||||||
@ -36,94 +41,114 @@ const (
|
|||||||
DefaultMaxLineLen = 512
|
DefaultMaxLineLen = 512
|
||||||
|
|
||||||
// IdentTimeout is how long before our ident (username) check times out.
|
// IdentTimeout is how long before our ident (username) check times out.
|
||||||
IdentTimeout = time.Second + 500*time.Millisecond
|
IdentTimeout = time.Second + 500*time.Millisecond
|
||||||
IRCv3TimestampFormat = utils.IRCv3TimestampFormat
|
|
||||||
// limit the number of device IDs a client can use, as a DoS mitigation
|
// limit the number of device IDs a client can use, as a DoS mitigation
|
||||||
maxDeviceIDsPerClient = 64
|
maxDeviceIDsPerClient = 64
|
||||||
// maximum total read markers that can be stored
|
// maximum total read markers that can be stored
|
||||||
// (writeback of read markers is controlled by lastSeen logic)
|
// (writeback of read markers is controlled by lastSeen logic)
|
||||||
maxReadMarkers = 256
|
maxReadMarkers = 256
|
||||||
|
|
||||||
|
// should be long enough to handle multiple notifications in rapid succession,
|
||||||
|
// short enough that it doesn't waste a lot of RAM per client
|
||||||
|
pushQueueLengthPerClient = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// idle timeouts for client connections, set from the config
|
||||||
|
RegisterTimeout, PingTimeout, DisconnectTimeout time.Duration
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// RegisterTimeout is how long clients have to register before we disconnect them
|
|
||||||
RegisterTimeout = time.Minute
|
|
||||||
// DefaultIdleTimeout is how long without traffic before we send the client a PING
|
|
||||||
DefaultIdleTimeout = time.Minute + 30*time.Second
|
|
||||||
// For Tor clients, we send a PING at least every 30 seconds, as a workaround for this bug
|
// For Tor clients, we send a PING at least every 30 seconds, as a workaround for this bug
|
||||||
// (single-onion circuits will close unless the client sends data once every 60 seconds):
|
// (single-onion circuits will close unless the client sends data once every 60 seconds):
|
||||||
// https://bugs.torproject.org/29665
|
// https://bugs.torproject.org/29665
|
||||||
TorIdleTimeout = time.Second * 30
|
TorPingTimeout = time.Second * 30
|
||||||
// This is how long a client gets without sending any message, including the PONG to our
|
|
||||||
// PING, before we disconnect them:
|
|
||||||
DefaultTotalTimeout = 2*time.Minute + 30*time.Second
|
|
||||||
|
|
||||||
// round off the ping interval by this much, see below:
|
// round off the ping interval by this much, see below:
|
||||||
PingCoalesceThreshold = time.Second
|
PingCoalesceThreshold = time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
utf8BOM = "\xef\xbb\xbf"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
MaxLineLen = DefaultMaxLineLen
|
MaxLineLen = DefaultMaxLineLen
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is an IRC client.
|
// Client is an IRC client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
account string
|
account string
|
||||||
accountName string // display name of the account: uncasefolded, '*' if not logged in
|
accountName string // display name of the account: uncasefolded, '*' if not logged in
|
||||||
accountRegDate time.Time
|
accountRegDate time.Time
|
||||||
accountSettings AccountSettings
|
accountSettings AccountSettings
|
||||||
awayMessage string
|
awayMessage string
|
||||||
channels ChannelSet
|
channels ChannelSet
|
||||||
ctime time.Time
|
ctime time.Time
|
||||||
destroyed bool
|
destroyed bool
|
||||||
modes modes.ModeSet
|
modes modes.ModeSet
|
||||||
hostname string
|
hostname string
|
||||||
invitedTo map[string]channelInvite
|
invitedTo map[string]channelInvite
|
||||||
isSTSOnly bool
|
isSTSOnly bool
|
||||||
isKlined bool // #1941: k-line kills are special-cased to suppress some triggered notices/events
|
isKlined bool // #1941: k-line kills are special-cased to suppress some triggered notices/events
|
||||||
languages []string
|
languages []string
|
||||||
lastActive time.Time // last time they sent a command that wasn't PONG or similar
|
lastActive time.Time // last time they sent a command that wasn't PONG or similar
|
||||||
lastSeen map[string]time.Time // maps device ID (including "") to time of last received command
|
lastSeen map[string]time.Time // maps device ID (including "") to time of last received command
|
||||||
readMarkers map[string]time.Time // maps casefolded target to time of last read marker
|
readMarkers map[string]time.Time // maps casefolded target to time of last read marker
|
||||||
loginThrottle connection_limits.GenericThrottle
|
loginThrottle connection_limits.GenericThrottle
|
||||||
nextSessionID int64 // Incremented when a new session is established
|
nextSessionID int64 // Incremented when a new session is established
|
||||||
nick string
|
nick string
|
||||||
nickCasefolded string
|
nickCasefolded string
|
||||||
nickMaskCasefolded string
|
nickMaskCasefolded string
|
||||||
nickMaskString string // cache for nickmask string since it's used with lots of replies
|
nickMaskString string // cache for nickmask string since it's used with lots of replies
|
||||||
oper *Oper
|
oper *Oper
|
||||||
preregNick string
|
preregNick string
|
||||||
proxiedIP net.IP // actual remote IP if using the PROXY protocol
|
proxiedIP net.IP // actual remote IP if using the PROXY protocol
|
||||||
rawHostname string
|
rawHostname string
|
||||||
cloakedHostname string
|
cloakedHostname string
|
||||||
realname string
|
realname string
|
||||||
realIP net.IP
|
realIP net.IP
|
||||||
requireSASLMessage string
|
requireSASLMessage string
|
||||||
requireSASL bool
|
requireSASL bool
|
||||||
registered bool
|
registered bool
|
||||||
registerCmdSent bool // already sent the draft/register command, can't send it again
|
registerCmdSent bool // already sent the draft/register command, can't send it again
|
||||||
dirtyTimestamps bool // lastSeen or readMarkers is dirty
|
dirtyTimestamps bool // lastSeen or readMarkers is dirty
|
||||||
registrationTimer *time.Timer
|
registrationTimer *time.Timer
|
||||||
server *Server
|
server *Server
|
||||||
skeleton string
|
skeleton string
|
||||||
sessions []*Session
|
sessions []*Session
|
||||||
stateMutex sync.RWMutex // tier 1
|
stateMutex sync.RWMutex // tier 1
|
||||||
alwaysOn bool
|
alwaysOn bool
|
||||||
username string
|
username string
|
||||||
vhost string
|
vhost string
|
||||||
history history.Buffer
|
history history.Buffer
|
||||||
dirtyBits uint
|
dirtyBits uint
|
||||||
writebackLock sync.Mutex // tier 1.5
|
writebackLock sync.Mutex // tier 1.5
|
||||||
|
pushSubscriptions map[string]*pushSubscription
|
||||||
|
cachedPushSubscriptions []storedPushSubscription
|
||||||
|
clearablePushMessages map[string]time.Time
|
||||||
|
pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0
|
||||||
|
pushQueue pushQueue
|
||||||
|
metadata map[string]string
|
||||||
|
metadataThrottle connection_limits.ThrottleDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
type saslStatus struct {
|
type saslStatus struct {
|
||||||
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:
|
||||||
@ -141,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
|
||||||
@ -149,17 +176,20 @@ type Session struct {
|
|||||||
idleTimer *time.Timer
|
idleTimer *time.Timer
|
||||||
pingSent bool // we sent PING to a putatively idle connection and we're waiting for PONG
|
pingSent bool // we sent PING to a putatively idle connection and we're waiting for PONG
|
||||||
|
|
||||||
sessionID int64
|
sessionID int64
|
||||||
socket *Socket
|
socket *Socket
|
||||||
realIP net.IP
|
realIP net.IP
|
||||||
proxiedIP net.IP
|
proxiedIP net.IP
|
||||||
rawHostname string
|
rawHostname string
|
||||||
isTor bool
|
hostnameFinalized bool
|
||||||
hideSTS bool
|
isTor bool
|
||||||
|
hideSTS bool
|
||||||
|
|
||||||
fakelag Fakelag
|
fakelag Fakelag
|
||||||
deferredFakelagCount int
|
deferredFakelagCount int
|
||||||
|
|
||||||
|
lastOperAttempt time.Time
|
||||||
|
|
||||||
certfp string
|
certfp string
|
||||||
peerCerts []*x509.Certificate
|
peerCerts []*x509.Certificate
|
||||||
sasl saslStatus
|
sasl saslStatus
|
||||||
@ -167,6 +197,8 @@ type Session struct {
|
|||||||
|
|
||||||
batchCounter atomic.Uint32
|
batchCounter atomic.Uint32
|
||||||
|
|
||||||
|
isupportSentPrereg bool
|
||||||
|
|
||||||
quitMessage string
|
quitMessage string
|
||||||
|
|
||||||
awayMessage string
|
awayMessage string
|
||||||
@ -182,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.
|
||||||
@ -295,7 +332,7 @@ func (server *Server) RunClient(conn IRCConn) {
|
|||||||
var banMsg string
|
var banMsg string
|
||||||
realIP := utils.AddrToIP(wConn.RemoteAddr())
|
realIP := utils.AddrToIP(wConn.RemoteAddr())
|
||||||
var proxiedIP net.IP
|
var proxiedIP net.IP
|
||||||
if wConn.Config.Tor {
|
if wConn.Tor {
|
||||||
// cover up details of the tor proxying infrastructure (not a user privacy concern,
|
// cover up details of the tor proxying infrastructure (not a user privacy concern,
|
||||||
// but a hardening measure):
|
// but a hardening measure):
|
||||||
proxiedIP = utils.IPv4LoopbackAddress
|
proxiedIP = utils.IPv4LoopbackAddress
|
||||||
@ -320,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:
|
||||||
@ -329,7 +367,7 @@ func (server *Server) RunClient(conn IRCConn) {
|
|||||||
lastActive: now,
|
lastActive: now,
|
||||||
channels: make(ChannelSet),
|
channels: make(ChannelSet),
|
||||||
ctime: now,
|
ctime: now,
|
||||||
isSTSOnly: wConn.Config.STSOnly,
|
isSTSOnly: wConn.STSOnly,
|
||||||
languages: server.Languages().Default(),
|
languages: server.Languages().Default(),
|
||||||
loginThrottle: connection_limits.GenericThrottle{
|
loginThrottle: connection_limits.GenericThrottle{
|
||||||
Duration: config.Accounts.LoginThrottling.Duration,
|
Duration: config.Accounts.LoginThrottling.Duration,
|
||||||
@ -358,9 +396,11 @@ func (server *Server) RunClient(conn IRCConn) {
|
|||||||
lastActive: now,
|
lastActive: now,
|
||||||
realIP: realIP,
|
realIP: realIP,
|
||||||
proxiedIP: proxiedIP,
|
proxiedIP: proxiedIP,
|
||||||
isTor: wConn.Config.Tor,
|
isTor: wConn.Tor,
|
||||||
hideSTS: wConn.Config.Tor || wConn.Config.HideSTS,
|
hideSTS: wConn.Tor || wConn.HideSTS,
|
||||||
|
connID: connID,
|
||||||
}
|
}
|
||||||
|
session.sasl.Initialize()
|
||||||
client.sessions = []*Session{session}
|
client.sessions = []*Session{session}
|
||||||
|
|
||||||
session.resetFakelag()
|
session.resetFakelag()
|
||||||
@ -369,11 +409,16 @@ func (server *Server) RunClient(conn IRCConn) {
|
|||||||
client.SetMode(modes.TLS, true)
|
client.SetMode(modes.TLS, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if wConn.Config.TLSConfig != nil {
|
if wConn.TLS {
|
||||||
// error is not useful to us here anyways so we can ignore it
|
// error is not useful to us here anyways so we can ignore it
|
||||||
session.certfp, session.peerCerts, _ = utils.GetCertFP(wConn.Conn, RegisterTimeout)
|
session.certfp, session.peerCerts, _ = utils.GetCertFP(wConn.Conn, RegisterTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.Server.InitialNotice != "" {
|
||||||
|
// send initial notice for HOPM to recognize
|
||||||
|
client.Send(nil, client.server.name, "NOTICE", "*", config.Server.InitialNotice)
|
||||||
|
}
|
||||||
|
|
||||||
if session.isTor {
|
if session.isTor {
|
||||||
session.rawHostname = config.Server.TorListeners.Vhost
|
session.rawHostname = config.Server.TorListeners.Vhost
|
||||||
client.rawHostname = session.rawHostname
|
client.rawHostname = session.rawHostname
|
||||||
@ -388,7 +433,7 @@ func (server *Server) RunClient(conn IRCConn) {
|
|||||||
client.run(session)
|
client.run(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string) {
|
func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string, pushSubscriptions []storedPushSubscription, metadata map[string]string) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
config := server.Config()
|
config := server.Config()
|
||||||
if lastSeen == nil && account.Settings.AutoreplayMissed {
|
if lastSeen == nil && account.Settings.AutoreplayMissed {
|
||||||
@ -465,6 +510,18 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m
|
|||||||
if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) {
|
if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) {
|
||||||
client.setAutoAwayNoMutex(config)
|
client.setAutoAwayNoMutex(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(pushSubscriptions) != 0 {
|
||||||
|
client.pushSubscriptions = make(map[string]*pushSubscription, len(pushSubscriptions))
|
||||||
|
for _, sub := range pushSubscriptions {
|
||||||
|
client.pushSubscriptions[sub.Endpoint] = newPushSubscription(sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.rebuildPushSubscriptionCache()
|
||||||
|
|
||||||
|
if len(metadata) != 0 {
|
||||||
|
client.metadata = metadata
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) resizeHistory(config *Config) {
|
func (client *Client) resizeHistory(config *Config) {
|
||||||
@ -476,12 +533,21 @@ func (client *Client) resizeHistory(config *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolve an IP to an IRC-ready hostname, using reverse DNS, forward-confirming if necessary,
|
// once we have the final IP address (from the connection itself or from proxy data),
|
||||||
// and sending appropriate notices to the client
|
// compute the various possibilities for the hostname:
|
||||||
func (client *Client) lookupHostname(session *Session, overwrite bool) {
|
// * In the default/recommended configuration, via the cloak algorithm
|
||||||
|
// * If hostname lookup is enabled, via (forward-confirmed) reverse DNS
|
||||||
|
// * If WEBIRC was used, possibly via the hostname passed on the WEBIRC line
|
||||||
|
func (client *Client) finalizeHostname(session *Session) {
|
||||||
|
// only allow this once, since registration can fail (e.g. if the nickname is in use)
|
||||||
|
if session.hostnameFinalized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.hostnameFinalized = true
|
||||||
|
|
||||||
if session.isTor {
|
if session.isTor {
|
||||||
return
|
return
|
||||||
} // else: even if cloaking is enabled, look up the real hostname to show to operators
|
}
|
||||||
|
|
||||||
config := client.server.Config()
|
config := client.server.Config()
|
||||||
ip := session.realIP
|
ip := session.realIP
|
||||||
@ -489,30 +555,27 @@ func (client *Client) lookupHostname(session *Session, overwrite bool) {
|
|||||||
ip = session.proxiedIP
|
ip = session.proxiedIP
|
||||||
}
|
}
|
||||||
|
|
||||||
var hostname string
|
// even if cloaking is enabled, we may want to look up the real hostname to show to operators:
|
||||||
lookupSuccessful := false
|
if session.rawHostname == "" {
|
||||||
if config.Server.lookupHostnames {
|
var hostname string
|
||||||
session.Notice("*** Looking up your hostname...")
|
lookupSuccessful := false
|
||||||
hostname, lookupSuccessful = utils.LookupHostname(ip, config.Server.ForwardConfirmHostnames)
|
if config.Server.lookupHostnames {
|
||||||
if lookupSuccessful {
|
session.Notice("*** Looking up your hostname...")
|
||||||
session.Notice("*** Found your hostname")
|
hostname, lookupSuccessful = utils.LookupHostname(ip, config.Server.ForwardConfirmHostnames)
|
||||||
|
if lookupSuccessful {
|
||||||
|
session.Notice("*** Found your hostname")
|
||||||
|
} else {
|
||||||
|
session.Notice("*** Couldn't look up your hostname")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
session.Notice("*** Couldn't look up your hostname")
|
hostname = utils.IPStringToHostname(ip.String())
|
||||||
}
|
}
|
||||||
} else {
|
session.rawHostname = hostname
|
||||||
hostname = utils.IPStringToHostname(ip.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
session.rawHostname = hostname
|
// these will be discarded if this is actually a reattach:
|
||||||
cloakedHostname := config.Server.Cloaks.ComputeCloak(ip)
|
client.rawHostname = session.rawHostname
|
||||||
client.stateMutex.Lock()
|
client.cloakedHostname = config.Server.Cloaks.ComputeCloak(ip)
|
||||||
defer client.stateMutex.Unlock()
|
|
||||||
// update the hostname if this is a new connection, but not if it's a reattach
|
|
||||||
if overwrite || client.rawHostname == "" {
|
|
||||||
client.rawHostname = hostname
|
|
||||||
client.cloakedHostname = cloakedHostname
|
|
||||||
client.updateNickMaskNoMutex()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) doIdentLookup(conn net.Conn) {
|
func (client *Client) doIdentLookup(conn net.Conn) {
|
||||||
@ -625,7 +688,7 @@ func (client *Client) run(session *Session) {
|
|||||||
isReattach := client.Registered()
|
isReattach := client.Registered()
|
||||||
if isReattach {
|
if isReattach {
|
||||||
client.Touch(session)
|
client.Touch(session)
|
||||||
client.playReattachMessages(session)
|
client.performReattach(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
firstLine := !isReattach
|
firstLine := !isReattach
|
||||||
@ -636,7 +699,7 @@ func (client *Client) run(session *Session) {
|
|||||||
if err == errInvalidUtf8 {
|
if err == errInvalidUtf8 {
|
||||||
invalidUtf8 = true // handle as normal, including labeling
|
invalidUtf8 = true // handle as normal, including labeling
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
client.server.logger.Debug("connect-ip", "read error from client", err.Error())
|
client.server.logger.Debug("connect-ip", session.connID, "read error from client", err.Error())
|
||||||
var quitMessage string
|
var quitMessage string
|
||||||
switch err {
|
switch err {
|
||||||
case ircreader.ErrReadQ:
|
case ircreader.ErrReadQ:
|
||||||
@ -649,7 +712,7 @@ func (client *Client) run(session *Session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if client.server.logger.IsLoggingRawIO() {
|
if client.server.logger.IsLoggingRawIO() {
|
||||||
client.server.logger.Debug("userinput", client.nick, "<- ", line)
|
client.server.logger.Debug("userinput", session.connID, client.nick, "<-", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
// special-cased handling of PROXY protocol, see `handleProxyCommand` for details:
|
// special-cased handling of PROXY protocol, see `handleProxyCommand` for details:
|
||||||
@ -681,8 +744,12 @@ func (client *Client) run(session *Session) {
|
|||||||
}
|
}
|
||||||
session.fakelag.Touch(command)
|
session.fakelag.Touch(command)
|
||||||
} else {
|
} else {
|
||||||
// DoS hardening, #505
|
if session.registrationMessages == 0 && httpVerbs.Has(msg.Command) {
|
||||||
|
client.Send(nil, client.server.name, ERR_UNKNOWNERROR, msg.Command, "This is not an HTTP server")
|
||||||
|
break
|
||||||
|
}
|
||||||
session.registrationMessages++
|
session.registrationMessages++
|
||||||
|
// DoS hardening, #505
|
||||||
if client.server.Config().Limits.RegistrationMessages < session.registrationMessages {
|
if client.server.Config().Limits.RegistrationMessages < session.registrationMessages {
|
||||||
client.Send(nil, client.server.name, ERR_UNKNOWNERROR, "*", client.t("You have sent too many registration messages"))
|
client.Send(nil, client.server.name, ERR_UNKNOWNERROR, "*", client.t("You have sent too many registration messages"))
|
||||||
break
|
break
|
||||||
@ -700,17 +767,16 @@ func (client *Client) run(session *Session) {
|
|||||||
continue
|
continue
|
||||||
} // else: proceed with the truncated line
|
} // else: proceed with the truncated line
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
client.Quit(client.t("Received malformed line"), session)
|
message := "Received malformed line"
|
||||||
|
if strings.HasPrefix(line, utf8BOM) {
|
||||||
|
message = "Received UTF-8 byte-order mark, which is invalid at the start of an IRC protocol message"
|
||||||
|
}
|
||||||
|
client.Quit(message, session)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd, exists := Commands[msg.Command]
|
var cmd Command
|
||||||
if !exists {
|
msg.Command, cmd = client.server.resolveCommand(msg.Command, invalidUtf8)
|
||||||
cmd = unknownCommand
|
|
||||||
} else if invalidUtf8 {
|
|
||||||
cmd = invalidUtf8Command
|
|
||||||
}
|
|
||||||
|
|
||||||
isExiting := cmd.Run(client.server, client, session, msg)
|
isExiting := cmd.Run(client.server, client, session, msg)
|
||||||
if isExiting {
|
if isExiting {
|
||||||
break
|
break
|
||||||
@ -722,7 +788,9 @@ func (client *Client) run(session *Session) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) playReattachMessages(session *Session) {
|
func (client *Client) performReattach(session *Session) {
|
||||||
|
client.applyPreregMetadata(session)
|
||||||
|
|
||||||
client.server.playRegistrationBurst(session)
|
client.server.playRegistrationBurst(session)
|
||||||
hasHistoryCaps := session.HasHistoryCaps()
|
hasHistoryCaps := session.HasHistoryCaps()
|
||||||
for _, channel := range session.client.Channels() {
|
for _, channel := range session.client.Channels() {
|
||||||
@ -746,6 +814,34 @@ func (client *Client) playReattachMessages(session *Session) {
|
|||||||
session.autoreplayMissedSince = time.Time{}
|
session.autoreplayMissedSince = time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (client *Client) applyPreregMetadata(session *Session) {
|
||||||
|
if session.metadataPreregVals == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
session.metadataPreregVals = nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
updates := client.UpdateMetadataFromPrereg(session.metadataPreregVals, client.server.Config().Metadata.MaxKeys)
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO this is expensive
|
||||||
|
friends := client.FriendsMonitors(caps.Metadata)
|
||||||
|
for _, s := range client.Sessions() {
|
||||||
|
if s != session {
|
||||||
|
friends.Add(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target := client.Nick()
|
||||||
|
for k, v := range updates {
|
||||||
|
broadcastMetadataUpdate(client.server, maps.Keys(friends), session, target, k, v, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// idle, quit, timers and timeouts
|
// idle, quit, timers and timeouts
|
||||||
//
|
//
|
||||||
@ -777,19 +873,19 @@ func (client *Client) updateIdleTimer(session *Session, now time.Time) {
|
|||||||
session.pingSent = false
|
session.pingSent = false
|
||||||
|
|
||||||
if session.idleTimer == nil {
|
if session.idleTimer == nil {
|
||||||
pingTimeout := DefaultIdleTimeout
|
pingTimeout := PingTimeout
|
||||||
if session.isTor {
|
if session.isTor && TorPingTimeout < pingTimeout {
|
||||||
pingTimeout = TorIdleTimeout
|
pingTimeout = TorPingTimeout
|
||||||
}
|
}
|
||||||
session.idleTimer = time.AfterFunc(pingTimeout, session.handleIdleTimeout)
|
session.idleTimer = time.AfterFunc(pingTimeout, session.handleIdleTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) handleIdleTimeout() {
|
func (session *Session) handleIdleTimeout() {
|
||||||
totalTimeout := DefaultTotalTimeout
|
totalTimeout := DisconnectTimeout
|
||||||
pingTimeout := DefaultIdleTimeout
|
pingTimeout := PingTimeout
|
||||||
if session.isTor {
|
if session.isTor && TorPingTimeout < pingTimeout {
|
||||||
pingTimeout = TorIdleTimeout
|
pingTimeout = TorPingTimeout
|
||||||
}
|
}
|
||||||
|
|
||||||
session.client.stateMutex.Lock()
|
session.client.stateMutex.Lock()
|
||||||
@ -850,7 +946,7 @@ func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.I
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
target = nick
|
target = nick
|
||||||
}
|
}
|
||||||
batchID = rb.StartNestedHistoryBatch(target)
|
batchID = rb.StartNestedBatch("chathistory", target)
|
||||||
|
|
||||||
isSelfMessage := func(item *history.Item) bool {
|
isSelfMessage := func(item *history.Item) bool {
|
||||||
// XXX: Params[0] is the message target. if the source of this message is an in-memory
|
// XXX: Params[0] is the message target. if the source of this message is an in-memory
|
||||||
@ -1077,6 +1173,7 @@ func (client *Client) SetNick(nick, nickCasefolded, skeleton string) (success bo
|
|||||||
client.nickCasefolded = nickCasefolded
|
client.nickCasefolded = nickCasefolded
|
||||||
client.skeleton = skeleton
|
client.skeleton = skeleton
|
||||||
client.updateNickMaskNoMutex()
|
client.updateNickMaskNoMutex()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1141,12 +1238,18 @@ func (client *Client) LoggedIntoAccount() bool {
|
|||||||
// (You must ensure separately that destroy() is called, e.g., by returning `true` from
|
// (You must ensure separately that destroy() is called, e.g., by returning `true` from
|
||||||
// the command handler or calling it yourself.)
|
// the command handler or calling it yourself.)
|
||||||
func (client *Client) Quit(message string, session *Session) {
|
func (client *Client) Quit(message string, session *Session) {
|
||||||
|
nuh := client.NickMaskString()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
setFinalData := func(sess *Session) {
|
setFinalData := func(sess *Session) {
|
||||||
message := sess.quitMessage
|
message := sess.quitMessage
|
||||||
var finalData []byte
|
var finalData []byte
|
||||||
// #364: don't send QUIT lines to unregistered clients
|
// #364: don't send QUIT lines to unregistered clients
|
||||||
if client.registered {
|
if client.registered {
|
||||||
quitMsg := ircmsg.MakeMessage(nil, client.nickMaskString, "QUIT", message)
|
quitMsg := ircmsg.MakeMessage(nil, nuh, "QUIT", message)
|
||||||
|
if sess.capabilities.Has(caps.ServerTime) {
|
||||||
|
quitMsg.SetTag("time", now.Format(utils.IRCv3TimestampFormat))
|
||||||
|
}
|
||||||
finalData, _ = quitMsg.LineBytesStrict(false, MaxLineLen)
|
finalData, _ = quitMsg.LineBytesStrict(false, MaxLineLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1222,14 +1325,11 @@ func (client *Client) destroy(session *Session) {
|
|||||||
client.destroyed = true
|
client.destroyed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
becameAutoAway := false
|
wasAway := client.awayMessage
|
||||||
var awayMessage string
|
if client.autoAwayEnabledNoMutex(config) {
|
||||||
if alwaysOn && persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) {
|
|
||||||
wasAway := client.awayMessage != ""
|
|
||||||
client.setAutoAwayNoMutex(config)
|
client.setAutoAwayNoMutex(config)
|
||||||
awayMessage = client.awayMessage
|
|
||||||
becameAutoAway = !wasAway && awayMessage != ""
|
|
||||||
}
|
}
|
||||||
|
nowAway := client.awayMessage
|
||||||
|
|
||||||
if client.registrationTimer != nil {
|
if client.registrationTimer != nil {
|
||||||
// unconditionally stop; if the client is still unregistered it must be destroyed
|
// unconditionally stop; if the client is still unregistered it must be destroyed
|
||||||
@ -1269,7 +1369,7 @@ func (client *Client) destroy(session *Session) {
|
|||||||
if !shouldDestroy {
|
if !shouldDestroy {
|
||||||
client.server.snomasks.Send(sno.LocalDisconnects, fmt.Sprintf(ircfmt.Unescape("Client session disconnected for [a:%s] [h:%s] [ip:%s]"), details.accountName, session.rawHostname, source))
|
client.server.snomasks.Send(sno.LocalDisconnects, fmt.Sprintf(ircfmt.Unescape("Client session disconnected for [a:%s] [h:%s] [ip:%s]"), details.accountName, session.rawHostname, source))
|
||||||
}
|
}
|
||||||
client.server.logger.Info("connect-ip", fmt.Sprintf("disconnecting session of %s from %s", details.nick, source))
|
client.server.logger.Info("connect-ip", session.connID, fmt.Sprintf("Disconnecting session of %s from %s", details.nick, source))
|
||||||
}
|
}
|
||||||
|
|
||||||
// decrement stats if we have no more sessions, even if the client will not be destroyed
|
// decrement stats if we have no more sessions, even if the client will not be destroyed
|
||||||
@ -1279,8 +1379,8 @@ func (client *Client) destroy(session *Session) {
|
|||||||
client.server.stats.Remove(registered, invisible, operator)
|
client.server.stats.Remove(registered, invisible, operator)
|
||||||
}
|
}
|
||||||
|
|
||||||
if becameAutoAway {
|
if !shouldDestroy && wasAway != nowAway {
|
||||||
dispatchAwayNotify(client, true, awayMessage)
|
dispatchAwayNotify(client, nowAway)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !shouldDestroy {
|
if !shouldDestroy {
|
||||||
@ -1288,10 +1388,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)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@ -1307,14 +1407,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)
|
||||||
}
|
}
|
||||||
@ -1404,7 +1507,7 @@ func (session *Session) sendFromClientInternal(blocking bool, serverTime time.Ti
|
|||||||
|
|
||||||
func composeMultilineBatch(batchID, fromNickMask, fromAccount string, isBot bool, tags map[string]string, command, target string, message utils.SplitMessage) (result []ircmsg.Message) {
|
func composeMultilineBatch(batchID, fromNickMask, fromAccount string, isBot bool, tags map[string]string, command, target string, message utils.SplitMessage) (result []ircmsg.Message) {
|
||||||
batchStart := ircmsg.MakeMessage(tags, fromNickMask, "BATCH", "+"+batchID, caps.MultilineBatchType, target)
|
batchStart := ircmsg.MakeMessage(tags, fromNickMask, "BATCH", "+"+batchID, caps.MultilineBatchType, target)
|
||||||
batchStart.SetTag("time", message.Time.Format(IRCv3TimestampFormat))
|
batchStart.SetTag("time", message.Time.Format(utils.IRCv3TimestampFormat))
|
||||||
batchStart.SetTag("msgid", message.Msgid)
|
batchStart.SetTag("msgid", message.Msgid)
|
||||||
if fromAccount != "*" {
|
if fromAccount != "*" {
|
||||||
batchStart.SetTag("account", fromAccount)
|
batchStart.SetTag("account", fromAccount)
|
||||||
@ -1428,27 +1531,27 @@ func composeMultilineBatch(batchID, fromNickMask, fromAccount string, isBot bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// these are all the output commands that MUST have their last param be a trailing.
|
// in practice, many clients require that the final parameter be a trailing
|
||||||
// this is needed because dumb clients like to treat trailing params separately from the
|
// (prefixed with `:`) even when this is not syntactically necessary.
|
||||||
// other params in messages.
|
// by default, force the following commands to use a trailing:
|
||||||
commandsThatMustUseTrailing = map[string]bool{
|
commandsThatMustUseTrailing = utils.SetLiteral(
|
||||||
"PRIVMSG": true,
|
"PRIVMSG",
|
||||||
"NOTICE": true,
|
"NOTICE",
|
||||||
|
RPL_WHOISCHANNELS,
|
||||||
RPL_WHOISCHANNELS: true,
|
RPL_USERHOST,
|
||||||
RPL_USERHOST: true,
|
|
||||||
|
|
||||||
// mirc's handling of RPL_NAMREPLY is broken:
|
// mirc's handling of RPL_NAMREPLY is broken:
|
||||||
// https://forums.mirc.com/ubbthreads.php/topics/266939/re-nick-list
|
// https://forums.mirc.com/ubbthreads.php/topics/266939/re-nick-list
|
||||||
RPL_NAMREPLY: true,
|
RPL_NAMREPLY,
|
||||||
}
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func forceTrailing(config *Config, command string) bool {
|
||||||
|
return config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing.Has(command)
|
||||||
|
}
|
||||||
|
|
||||||
// SendRawMessage sends a raw message to the client.
|
// SendRawMessage sends a raw message to the client.
|
||||||
func (session *Session) SendRawMessage(message ircmsg.Message, blocking bool) error {
|
func (session *Session) SendRawMessage(message ircmsg.Message, blocking bool) error {
|
||||||
// use dumb hack to force the last param to be a trailing param if required
|
if forceTrailing(session.client.server.Config(), message.Command) {
|
||||||
config := session.client.server.Config()
|
|
||||||
if config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing[message.Command] {
|
|
||||||
message.ForceTrailing()
|
message.ForceTrailing()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1476,7 +1579,7 @@ func (session *Session) SendRawMessage(message ircmsg.Message, blocking bool) er
|
|||||||
func (session *Session) sendBytes(line []byte, blocking bool) (err error) {
|
func (session *Session) sendBytes(line []byte, blocking bool) (err error) {
|
||||||
if session.client.server.logger.IsLoggingRawIO() {
|
if session.client.server.logger.IsLoggingRawIO() {
|
||||||
logline := string(line[:len(line)-2]) // strip "\r\n"
|
logline := string(line[:len(line)-2]) // strip "\r\n"
|
||||||
session.client.server.logger.Debug("useroutput", session.client.Nick(), " ->", logline)
|
session.client.server.logger.Debug("useroutput", session.connID, session.client.Nick(), "->", logline)
|
||||||
}
|
}
|
||||||
|
|
||||||
if blocking {
|
if blocking {
|
||||||
@ -1485,7 +1588,7 @@ func (session *Session) sendBytes(line []byte, blocking bool) (err error) {
|
|||||||
err = session.socket.Write(line)
|
err = session.socket.Write(line)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.client.server.logger.Info("quit", "send error to client", fmt.Sprintf("%s [%d]", session.client.Nick(), session.sessionID), err.Error())
|
session.client.server.logger.Info("quit", session.connID, "send error to client", session.client.Nick(), err.Error())
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -1512,7 +1615,7 @@ func (session *Session) setTimeTag(msg *ircmsg.Message, serverTime time.Time) {
|
|||||||
if serverTime.IsZero() {
|
if serverTime.IsZero() {
|
||||||
serverTime = time.Now()
|
serverTime = time.Now()
|
||||||
}
|
}
|
||||||
msg.SetTag("time", serverTime.UTC().Format(IRCv3TimestampFormat))
|
msg.SetTag("time", serverTime.UTC().Format(utils.IRCv3TimestampFormat))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1671,12 +1774,15 @@ func (client *Client) addHistoryItem(target *Client, item history.Item, details,
|
|||||||
}
|
}
|
||||||
if cStatus == HistoryPersistent || tStatus == HistoryPersistent {
|
if cStatus == HistoryPersistent || tStatus == HistoryPersistent {
|
||||||
targetedItem.CfCorrespondent = ""
|
targetedItem.CfCorrespondent = ""
|
||||||
client.server.historyDB.AddDirectMessage(details.nickCasefolded, details.account, tDetails.nickCasefolded, tDetails.account, targetedItem)
|
err = client.server.historyDB.AddDirectMessage(details.nickCasefolded, details.account, tDetails.nickCasefolded, tDetails.account, targetedItem)
|
||||||
|
if err != nil {
|
||||||
|
client.server.logger.Error("history", "could not add direct message to history", err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) listTargets(start, end history.Selector, limit int) (results []history.TargetListing, err error) {
|
func (client *Client) listTargets(start, end time.Time, limit int) (results []history.TargetListing, err error) {
|
||||||
var base, extras []history.TargetListing
|
var base, extras []history.TargetListing
|
||||||
var chcfnames []string
|
var chcfnames []string
|
||||||
for _, channel := range client.Channels() {
|
for _, channel := range client.Channels() {
|
||||||
@ -1697,27 +1803,35 @@ func (client *Client) listTargets(start, end history.Selector, limit int) (resul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
persistentExtras, err := client.server.historyDB.ListChannels(chcfnames)
|
persistentExtras, err := client.server.historyDB.ListChannels(chcfnames)
|
||||||
if err == nil && len(persistentExtras) != 0 {
|
if err != nil {
|
||||||
|
client.server.logger.Error("history", "could not list persistent channels", err.Error())
|
||||||
|
} else if len(persistentExtras) != 0 {
|
||||||
extras = append(extras, persistentExtras...)
|
extras = append(extras, persistentExtras...)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, cSeq, err := client.server.GetHistorySequence(nil, client, "")
|
// get DM correspondents from the in-memory buffer or the database, as applicable
|
||||||
if err == nil && cSeq != nil {
|
var cErr error
|
||||||
correspondents, err := cSeq.ListCorrespondents(start, end, limit)
|
status, target := client.historyStatus(client.server.Config())
|
||||||
if err == nil {
|
switch status {
|
||||||
base = correspondents
|
case HistoryEphemeral:
|
||||||
}
|
base, cErr = client.history.ListCorrespondents(start, end, limit)
|
||||||
|
case HistoryPersistent:
|
||||||
|
base, cErr = client.server.historyDB.ListCorrespondents(target, start, end, limit)
|
||||||
|
default:
|
||||||
|
// nothing to do
|
||||||
|
}
|
||||||
|
if cErr != nil {
|
||||||
|
base = nil
|
||||||
|
client.server.logger.Error("history", "could not list correspondents", cErr.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
results = history.MergeTargets(base, extras, start.Time, end.Time, limit)
|
results = history.MergeTargets(base, extras, start, end, limit)
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// latest PRIVMSG from all DM targets
|
// latest PRIVMSG from all DM targets
|
||||||
func (client *Client) privmsgsBetween(startTime, endTime time.Time, targetLimit, messageLimit int) (results []history.Item, err error) {
|
func (client *Client) privmsgsBetween(startTime, endTime time.Time, targetLimit, messageLimit int) (results []history.Item, err error) {
|
||||||
start := history.Selector{Time: startTime}
|
targets, err := client.listTargets(startTime, endTime, targetLimit)
|
||||||
end := history.Selector{Time: endTime}
|
|
||||||
targets, err := client.listTargets(start, end, targetLimit)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1727,7 +1841,7 @@ func (client *Client) privmsgsBetween(startTime, endTime time.Time, targetLimit,
|
|||||||
}
|
}
|
||||||
_, seq, err := client.server.GetHistorySequence(nil, client, target.CfName)
|
_, seq, err := client.server.GetHistorySequence(nil, client, target.CfName)
|
||||||
if err == nil && seq != nil {
|
if err == nil && seq != nil {
|
||||||
items, err := seq.Between(start, end, messageLimit)
|
items, err := seq.Between(history.Selector{Time: startTime}, history.Selector{Time: endTime}, messageLimit)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
results = append(results, items...)
|
results = append(results, items...)
|
||||||
} else {
|
} else {
|
||||||
@ -1746,7 +1860,7 @@ func (client *Client) handleRegisterTimeout() {
|
|||||||
func (client *Client) copyLastSeen() (result map[string]time.Time) {
|
func (client *Client) copyLastSeen() (result map[string]time.Time) {
|
||||||
client.stateMutex.RLock()
|
client.stateMutex.RLock()
|
||||||
defer client.stateMutex.RUnlock()
|
defer client.stateMutex.RUnlock()
|
||||||
return utils.CopyMap(client.lastSeen)
|
return maps.Clone(client.lastSeen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// these are bit flags indicating what part of the client status is "dirty"
|
// these are bit flags indicating what part of the client status is "dirty"
|
||||||
@ -1755,6 +1869,8 @@ const (
|
|||||||
IncludeChannels uint = 1 << iota
|
IncludeChannels uint = 1 << iota
|
||||||
IncludeUserModes
|
IncludeUserModes
|
||||||
IncludeRealname
|
IncludeRealname
|
||||||
|
IncludePushSubscriptions
|
||||||
|
IncludeMetadata
|
||||||
)
|
)
|
||||||
|
|
||||||
func (client *Client) markDirty(dirtyBits uint) {
|
func (client *Client) markDirty(dirtyBits uint) {
|
||||||
@ -1775,6 +1891,8 @@ func (client *Client) wakeWriter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) writeLoop() {
|
func (client *Client) writeLoop() {
|
||||||
|
defer client.server.HandlePanic(nil)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
client.performWrite(0)
|
client.performWrite(0)
|
||||||
client.writebackLock.Unlock()
|
client.writebackLock.Unlock()
|
||||||
@ -1805,7 +1923,11 @@ func (client *Client) performWrite(additionalDirtyBits uint) {
|
|||||||
channels := client.Channels()
|
channels := client.Channels()
|
||||||
channelToModes := make(map[string]alwaysOnChannelStatus, len(channels))
|
channelToModes := make(map[string]alwaysOnChannelStatus, len(channels))
|
||||||
for _, channel := range channels {
|
for _, channel := range channels {
|
||||||
chname, status := channel.alwaysOnStatus(client)
|
ok, chname, status := channel.alwaysOnStatus(client)
|
||||||
|
if !ok {
|
||||||
|
client.server.logger.Error("internal", "client and channel membership out of sync", chname, client.Nick())
|
||||||
|
continue
|
||||||
|
}
|
||||||
channelToModes[chname] = status
|
channelToModes[chname] = status
|
||||||
}
|
}
|
||||||
client.server.accounts.saveChannels(account, channelToModes)
|
client.server.accounts.saveChannels(account, channelToModes)
|
||||||
@ -1827,6 +1949,12 @@ func (client *Client) performWrite(additionalDirtyBits uint) {
|
|||||||
if (dirtyBits & IncludeRealname) != 0 {
|
if (dirtyBits & IncludeRealname) != 0 {
|
||||||
client.server.accounts.saveRealname(account, client.realname)
|
client.server.accounts.saveRealname(account, client.realname)
|
||||||
}
|
}
|
||||||
|
if (dirtyBits & IncludePushSubscriptions) != 0 {
|
||||||
|
client.server.accounts.savePushSubscriptions(account, client.getPushSubscriptions(true))
|
||||||
|
}
|
||||||
|
if (dirtyBits & IncludeMetadata) != 0 {
|
||||||
|
client.server.accounts.saveMetadata(account, client.ListMetadata())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blocking store; see Channel.Store and Socket.BlockingWrite
|
// Blocking store; see Channel.Store and Socket.BlockingWrite
|
||||||
@ -1846,3 +1974,134 @@ func (client *Client) Store(dirtyBits uint) (err error) {
|
|||||||
client.performWrite(dirtyBits)
|
client.performWrite(dirtyBits)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pushSubscription represents all the data we track about the state of a push subscription;
|
||||||
|
// right now every field is persisted, but we may want to persist only a subset in future
|
||||||
|
type pushSubscription struct {
|
||||||
|
storedPushSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
// storedPushSubscription represents a subscription as stored in the database
|
||||||
|
type storedPushSubscription struct {
|
||||||
|
Endpoint string
|
||||||
|
Keys webpush.Keys
|
||||||
|
LastRefresh time.Time // last time the client sent WEBPUSH REGISTER for this endpoint
|
||||||
|
LastSuccess time.Time // last time we successfully pushed to this endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPushSubscription(sub storedPushSubscription) *pushSubscription {
|
||||||
|
return &pushSubscription{
|
||||||
|
storedPushSubscription: sub,
|
||||||
|
// TODO any other initialization here, like rate limiting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pushMessage struct {
|
||||||
|
msg []byte
|
||||||
|
urgency webpush.Urgency
|
||||||
|
originatingEndpoint string
|
||||||
|
cftarget string
|
||||||
|
time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type pushQueue struct {
|
||||||
|
workerLock sync.Mutex
|
||||||
|
queue chan pushMessage
|
||||||
|
once sync.Once
|
||||||
|
dropped atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensurePushInitialized() {
|
||||||
|
c.pushQueue.once.Do(c.initializePush)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) initializePush() {
|
||||||
|
// allocate the queue
|
||||||
|
c.pushQueue.queue = make(chan pushMessage, pushQueueLengthPerClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) dispatchPushMessage(msg pushMessage) {
|
||||||
|
client.ensurePushInitialized()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case client.pushQueue.queue <- msg:
|
||||||
|
if client.pushQueue.workerLock.TryLock() {
|
||||||
|
go client.pushWorker()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
client.pushQueue.dropped.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) pushWorker() {
|
||||||
|
defer client.server.HandlePanic(nil)
|
||||||
|
defer client.pushQueue.workerLock.Unlock()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg := <-client.pushQueue.queue:
|
||||||
|
for _, sub := range client.getPushSubscriptions(false) {
|
||||||
|
if !client.skipPushMessage(msg) {
|
||||||
|
client.sendAndTrackPush(sub.Endpoint, sub.Keys, msg, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// no more messages, end the goroutine and release the trylock
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipPushMessage waits up to the configured delay for the client to send MARKREAD;
|
||||||
|
// it returns whether the message has been read
|
||||||
|
func (client *Client) skipPushMessage(msg pushMessage) bool {
|
||||||
|
if msg.cftarget == "" || msg.time.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
config := client.server.Config()
|
||||||
|
if config.WebPush.Delay == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
deadline := msg.time.Add(config.WebPush.Delay)
|
||||||
|
pause := time.Until(deadline)
|
||||||
|
if pause > 0 {
|
||||||
|
time.Sleep(pause)
|
||||||
|
}
|
||||||
|
readTimestamp, ok := client.getMarkreadTime(msg.cftarget)
|
||||||
|
return ok && utils.ReadMarkerLessThanOrEqual(msg.time, readTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) sendAndTrackPush(endpoint string, keys webpush.Keys, msg pushMessage, updateDB bool) {
|
||||||
|
if endpoint == msg.originatingEndpoint {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg.cftarget != "" && !msg.time.IsZero() {
|
||||||
|
client.addClearablePushMessage(msg.cftarget, msg.time)
|
||||||
|
}
|
||||||
|
switch client.sendPush(endpoint, keys, msg.urgency, msg.msg) {
|
||||||
|
case nil:
|
||||||
|
client.recordPush(endpoint, true)
|
||||||
|
case webpush.Err404:
|
||||||
|
client.deletePushSubscription(endpoint, updateDB)
|
||||||
|
default:
|
||||||
|
client.recordPush(endpoint, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) sendPush(endpoint string, keys webpush.Keys, urgency webpush.Urgency, msg []byte) error {
|
||||||
|
config := client.server.Config()
|
||||||
|
// final sanity check
|
||||||
|
if !config.WebPush.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), config.WebPush.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := webpush.SendWebPush(ctx, endpoint, keys, config.WebPush.vapidKeys, webpush.UrgencyHigh, config.WebPush.Subscriber, msg)
|
||||||
|
if err == nil {
|
||||||
|
client.server.logger.Debug("webpush", "dispatched push to client", client.Nick(), endpoint)
|
||||||
|
} else {
|
||||||
|
client.server.logger.Debug("webpush", "failed to dispatch push to client", client.Nick(), endpoint, err.Error())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@ -84,7 +84,7 @@ func (clients *ClientManager) Remove(client *Client) error {
|
|||||||
// SetNick sets a client's nickname, validating it against nicknames in use
|
// SetNick sets a client's nickname, validating it against nicknames in use
|
||||||
// XXX: dryRun validates a client's ability to claim a nick, without
|
// XXX: dryRun validates a client's ability to claim a nick, without
|
||||||
// actually claiming it
|
// actually claiming it
|
||||||
func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string, dryRun bool) (setNick string, err error, returnedFromAway bool) {
|
func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string, dryRun bool) (setNick string, err error, awayChanged bool) {
|
||||||
config := client.server.Config()
|
config := client.server.Config()
|
||||||
|
|
||||||
var newCfNick, newSkeleton string
|
var newCfNick, newSkeleton string
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,16 +198,7 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick
|
|||||||
dryRun || session == nil {
|
dryRun || session == nil {
|
||||||
return "", errNicknameInUse, false
|
return "", errNicknameInUse, false
|
||||||
}
|
}
|
||||||
// check TLS modes
|
reattachSuccessful, numSessions, lastSeen, wasAway, nowAway := currentClient.AddSession(session)
|
||||||
if client.HasMode(modes.TLS) != currentClient.HasMode(modes.TLS) {
|
|
||||||
if useAccountName {
|
|
||||||
// #955: this is fatal because they can't fix it by trying a different nick
|
|
||||||
return "", errInsecureReattach, false
|
|
||||||
} else {
|
|
||||||
return "", errNicknameInUse, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reattachSuccessful, numSessions, lastSeen, back := currentClient.AddSession(session)
|
|
||||||
if !reattachSuccessful {
|
if !reattachSuccessful {
|
||||||
return "", errNicknameInUse, false
|
return "", errNicknameInUse, false
|
||||||
}
|
}
|
||||||
@ -214,12 +208,8 @@ 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, back
|
return newNick, nil, wasAway != nowAway
|
||||||
} else if currentClient == client && currentClient.Nick() == newNick {
|
} else if currentClient == client && currentClient.Nick() == newNick {
|
||||||
return "", errNoop, false
|
return "", errNoop, false
|
||||||
}
|
}
|
||||||
@ -228,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
|
||||||
@ -255,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -272,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)
|
||||||
|
|||||||
@ -4,8 +4,10 @@
|
|||||||
package irc
|
package irc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/languages"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -30,6 +32,47 @@ func BenchmarkGenerateBatchID(b *testing.B) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BenchmarkNames(b *testing.B) {
|
||||||
|
channelSize := 1024
|
||||||
|
server := &Server{
|
||||||
|
name: "ergo.test",
|
||||||
|
}
|
||||||
|
lm, err := languages.NewManager(false, "", "")
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
server.config.Store(&Config{
|
||||||
|
languageManager: lm,
|
||||||
|
})
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
channel := &Channel{
|
||||||
|
name: "#test",
|
||||||
|
nameCasefolded: "#test",
|
||||||
|
server: server,
|
||||||
|
members: make(MemberSet),
|
||||||
|
}
|
||||||
|
for j := 0; j < channelSize; j++ {
|
||||||
|
nick := fmt.Sprintf("client_%d", j)
|
||||||
|
client := &Client{
|
||||||
|
server: server,
|
||||||
|
nick: nick,
|
||||||
|
nickCasefolded: nick,
|
||||||
|
}
|
||||||
|
channel.members.Add(client)
|
||||||
|
channel.regenerateMembersCache()
|
||||||
|
session := &Session{
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
|
rb := NewResponseBuffer(session)
|
||||||
|
channel.Names(client, rb)
|
||||||
|
if len(rb.messages) < 2 {
|
||||||
|
b.Fatalf("not enough messages: %d", len(rb.messages))
|
||||||
|
}
|
||||||
|
// to inspect the messages: line, _ := rb.messages[0].Line()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserMasks(t *testing.T) {
|
func TestUserMasks(t *testing.T) {
|
||||||
var um UserMaskSet
|
var um UserMaskSet
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
@ -89,8 +107,9 @@ func init() {
|
|||||||
minParams: 1,
|
minParams: 1,
|
||||||
},
|
},
|
||||||
"AWAY": {
|
"AWAY": {
|
||||||
handler: awayHandler,
|
handler: awayHandler,
|
||||||
minParams: 0,
|
usablePreReg: true,
|
||||||
|
minParams: 0,
|
||||||
},
|
},
|
||||||
"BATCH": {
|
"BATCH": {
|
||||||
handler: batchHandler,
|
handler: batchHandler,
|
||||||
@ -151,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,
|
||||||
@ -186,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,
|
||||||
@ -300,6 +328,10 @@ func init() {
|
|||||||
usablePreReg: true,
|
usablePreReg: true,
|
||||||
minParams: 0,
|
minParams: 0,
|
||||||
},
|
},
|
||||||
|
"REDACT": {
|
||||||
|
handler: redactHandler,
|
||||||
|
minParams: 2,
|
||||||
|
},
|
||||||
"REHASH": {
|
"REHASH": {
|
||||||
handler: rehashHandler,
|
handler: rehashHandler,
|
||||||
minParams: 0,
|
minParams: 0,
|
||||||
@ -358,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,
|
||||||
|
|||||||
489
irc/config.go
489
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"
|
||||||
@ -32,14 +33,23 @@ import (
|
|||||||
"github.com/ergochat/ergo/irc/connection_limits"
|
"github.com/ergochat/ergo/irc/connection_limits"
|
||||||
"github.com/ergochat/ergo/irc/custime"
|
"github.com/ergochat/ergo/irc/custime"
|
||||||
"github.com/ergochat/ergo/irc/email"
|
"github.com/ergochat/ergo/irc/email"
|
||||||
|
"github.com/ergochat/ergo/irc/i18n"
|
||||||
"github.com/ergochat/ergo/irc/isupport"
|
"github.com/ergochat/ergo/irc/isupport"
|
||||||
"github.com/ergochat/ergo/irc/jwt"
|
"github.com/ergochat/ergo/irc/jwt"
|
||||||
"github.com/ergochat/ergo/irc/languages"
|
"github.com/ergochat/ergo/irc/languages"
|
||||||
"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/postgresql"
|
||||||
|
"github.com/ergochat/ergo/irc/sqlite"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultProxyDeadline = 5 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// here's how this works: exported (capitalized) members of the config structs
|
// here's how this works: exported (capitalized) members of the config structs
|
||||||
@ -303,7 +313,7 @@ func (t *ThrottleConfig) UnmarshalYAML(unmarshal func(interface{}) error) (err e
|
|||||||
type AccountConfig struct {
|
type AccountConfig struct {
|
||||||
Registration AccountRegistrationConfig
|
Registration AccountRegistrationConfig
|
||||||
AuthenticationEnabled bool `yaml:"authentication-enabled"`
|
AuthenticationEnabled bool `yaml:"authentication-enabled"`
|
||||||
AdvertiseSCRAM bool `yaml:"advertise-scram"` // undocumented, see #1782
|
AdvertiseSCRAM bool `yaml:"advertise-scram"`
|
||||||
RequireSasl struct {
|
RequireSasl struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Exempted []string
|
Exempted []string
|
||||||
@ -331,7 +341,9 @@ type AccountConfig struct {
|
|||||||
Multiclient MulticlientConfig
|
Multiclient MulticlientConfig
|
||||||
Bouncer *MulticlientConfig // # handle old name for 'multiclient'
|
Bouncer *MulticlientConfig // # handle old name for 'multiclient'
|
||||||
VHosts VHostConfig
|
VHosts VHostConfig
|
||||||
AuthScript AuthScriptConfig `yaml:"auth-script"`
|
AuthScript AuthScriptConfig `yaml:"auth-script"`
|
||||||
|
OAuth2 oauth2.OAuth2BearerConfig `yaml:"oauth2"`
|
||||||
|
JWTAuth jwt.JWTAuthConfig `yaml:"jwt-auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScriptConfig struct {
|
type ScriptConfig struct {
|
||||||
@ -366,7 +378,7 @@ type AccountRegistrationConfig struct {
|
|||||||
Mailto email.MailtoConfig
|
Mailto email.MailtoConfig
|
||||||
} `yaml:"callbacks"`
|
} `yaml:"callbacks"`
|
||||||
VerifyTimeout custime.Duration `yaml:"verify-timeout"`
|
VerifyTimeout custime.Duration `yaml:"verify-timeout"`
|
||||||
BcryptCost uint `yaml:"bcrypt-cost"`
|
BcryptCost int `yaml:"bcrypt-cost"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VHostConfig struct {
|
type VHostConfig struct {
|
||||||
@ -436,27 +448,6 @@ func (nr *NickEnforcementMethod) UnmarshalYAML(unmarshal func(interface{}) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *Casemapping) UnmarshalYAML(unmarshal func(interface{}) error) (err error) {
|
|
||||||
var orig string
|
|
||||||
if err = unmarshal(&orig); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result Casemapping
|
|
||||||
switch strings.ToLower(orig) {
|
|
||||||
case "ascii":
|
|
||||||
result = CasemappingASCII
|
|
||||||
case "precis", "rfc7613", "rfc8265":
|
|
||||||
result = CasemappingPRECIS
|
|
||||||
case "permissive", "fun":
|
|
||||||
result = CasemappingPermissive
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid casemapping value: %s", orig)
|
|
||||||
}
|
|
||||||
*cm = result
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OperClassConfig defines a specific operator class.
|
// OperClassConfig defines a specific operator class.
|
||||||
type OperClassConfig struct {
|
type OperClassConfig struct {
|
||||||
Title string
|
Title string
|
||||||
@ -484,6 +475,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"`
|
||||||
@ -566,8 +558,14 @@ type Config struct {
|
|||||||
CoerceIdent string `yaml:"coerce-ident"`
|
CoerceIdent string `yaml:"coerce-ident"`
|
||||||
MOTD string
|
MOTD string
|
||||||
motdLines []string
|
motdLines []string
|
||||||
MOTDFormatting bool `yaml:"motd-formatting"`
|
MOTDFormatting bool `yaml:"motd-formatting"`
|
||||||
Relaymsg struct {
|
InitialNotice string `yaml:"initial-notice"`
|
||||||
|
IdleTimeouts struct {
|
||||||
|
Registration time.Duration
|
||||||
|
Ping time.Duration
|
||||||
|
Disconnect time.Duration
|
||||||
|
} `yaml:"idle-timeouts"`
|
||||||
|
Relaymsg struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Separators string
|
Separators string
|
||||||
AvailableToChanops bool `yaml:"available-to-chanops"`
|
AvailableToChanops bool `yaml:"available-to-chanops"`
|
||||||
@ -589,24 +587,38 @@ type Config struct {
|
|||||||
Cloaks cloaks.CloakConfig `yaml:"ip-cloaking"`
|
Cloaks cloaks.CloakConfig `yaml:"ip-cloaking"`
|
||||||
SecureNetDefs []string `yaml:"secure-nets"`
|
SecureNetDefs []string `yaml:"secure-nets"`
|
||||||
secureNets []net.IPNet
|
secureNets []net.IPNet
|
||||||
|
OperThrottle time.Duration `yaml:"oper-throttle"`
|
||||||
supportedCaps *caps.Set
|
supportedCaps *caps.Set
|
||||||
supportedCapsWithoutSTS *caps.Set
|
supportedCapsWithoutSTS *caps.Set
|
||||||
capValues caps.Values
|
capValues caps.Values
|
||||||
Casemapping Casemapping
|
Casemapping i18n.Casemapping
|
||||||
EnforceUtf8 bool `yaml:"enforce-utf8"`
|
EnforceUtf8 bool `yaml:"enforce-utf8"`
|
||||||
OutputPath string `yaml:"output-path"`
|
OutputPath string `yaml:"output-path"`
|
||||||
IPCheckScript IPCheckScriptConfig `yaml:"ip-check-script"`
|
IPCheckScript IPCheckScriptConfig `yaml:"ip-check-script"`
|
||||||
OverrideServicesHostname string `yaml:"override-services-hostname"`
|
OverrideServicesHostname string `yaml:"override-services-hostname"`
|
||||||
MaxLineLen int `yaml:"max-line-len"`
|
MaxLineLen int `yaml:"max-line-len"`
|
||||||
SuppressLusers bool `yaml:"suppress-lusers"`
|
SuppressLusers bool `yaml:"suppress-lusers"`
|
||||||
|
AdditionalISupport map[string]string `yaml:"additional-isupport"`
|
||||||
|
CommandAliases map[string]string `yaml:"command-aliases"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
API struct {
|
||||||
|
Enabled bool
|
||||||
|
Listener string
|
||||||
|
TLS TLSListenConfig
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
BearerTokens []string `yaml:"bearer-tokens"`
|
||||||
|
bearerTokenBytes [][]byte
|
||||||
|
} `yaml:"api"`
|
||||||
|
|
||||||
Roleplay struct {
|
Roleplay struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
RequireChanops bool `yaml:"require-chanops"`
|
RequireChanops bool `yaml:"require-chanops"`
|
||||||
RequireOper bool `yaml:"require-oper"`
|
RequireOper bool `yaml:"require-oper"`
|
||||||
AddSuffix *bool `yaml:"add-suffix"`
|
AddSuffix *bool `yaml:"add-suffix"`
|
||||||
addSuffix bool
|
addSuffix bool
|
||||||
|
NPCNickMask string `yaml:"npc-nick-mask"`
|
||||||
|
SceneNickMask string `yaml:"scene-nick-mask"`
|
||||||
}
|
}
|
||||||
|
|
||||||
Extjwt struct {
|
Extjwt struct {
|
||||||
@ -628,6 +640,8 @@ type Config struct {
|
|||||||
Path string
|
Path string
|
||||||
AutoUpgrade bool
|
AutoUpgrade bool
|
||||||
MySQL mysql.Config
|
MySQL mysql.Config
|
||||||
|
PostgreSQL postgresql.Config
|
||||||
|
SQLite sqlite.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
Accounts AccountConfig
|
Accounts AccountConfig
|
||||||
@ -644,6 +658,7 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
ListDelay time.Duration `yaml:"list-delay"`
|
ListDelay time.Duration `yaml:"list-delay"`
|
||||||
InviteExpiration custime.Duration `yaml:"invite-expiration"`
|
InviteExpiration custime.Duration `yaml:"invite-expiration"`
|
||||||
|
AutoJoin []string `yaml:"auto-join"`
|
||||||
}
|
}
|
||||||
|
|
||||||
OperClasses map[string]*OperClassConfig `yaml:"oper-classes"`
|
OperClasses map[string]*OperClassConfig `yaml:"oper-classes"`
|
||||||
@ -699,6 +714,25 @@ type Config struct {
|
|||||||
} `yaml:"tagmsg-storage"`
|
} `yaml:"tagmsg-storage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Metadata struct {
|
||||||
|
Enabled bool
|
||||||
|
OperatorOnlyModification bool `yaml:"operator-only-modification"`
|
||||||
|
MaxSubs int `yaml:"max-subs"`
|
||||||
|
MaxKeys int `yaml:"max-keys"`
|
||||||
|
MaxValueBytes int `yaml:"max-value-length"`
|
||||||
|
ClientThrottle ThrottleConfig `yaml:"client-throttle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
WebPush struct {
|
||||||
|
Enabled bool
|
||||||
|
Timeout time.Duration
|
||||||
|
Delay time.Duration
|
||||||
|
Subscriber string
|
||||||
|
MaxSubscriptions int `yaml:"max-subscriptions"`
|
||||||
|
Expiration custime.Duration
|
||||||
|
vapidKeys *webpush.VAPIDKeys
|
||||||
|
} `yaml:"webpush"`
|
||||||
|
|
||||||
Filename string
|
Filename string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -945,7 +979,7 @@ func (conf *Config) prepareListeners() (err error) {
|
|||||||
conf.Server.trueListeners = make(map[string]utils.ListenerConfig)
|
conf.Server.trueListeners = make(map[string]utils.ListenerConfig)
|
||||||
for addr, block := range conf.Server.Listeners {
|
for addr, block := range conf.Server.Listeners {
|
||||||
var lconf utils.ListenerConfig
|
var lconf utils.ListenerConfig
|
||||||
lconf.ProxyDeadline = RegisterTimeout
|
lconf.ProxyDeadline = defaultProxyDeadline
|
||||||
lconf.Tor = block.Tor
|
lconf.Tor = block.Tor
|
||||||
lconf.STSOnly = block.STSOnly
|
lconf.STSOnly = block.STSOnly
|
||||||
if lconf.STSOnly && !conf.Server.STS.Enabled {
|
if lconf.STSOnly && !conf.Server.STS.Enabled {
|
||||||
@ -989,6 +1023,40 @@ func (config *Config) processExtjwt() (err error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (config *Config) processAPI() (err error) {
|
||||||
|
if !config.API.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.API.Listener == "" {
|
||||||
|
return errors.New("config.api.enabled is true, but listener address is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
config.API.bearerTokenBytes = make([][]byte, len(config.API.BearerTokens))
|
||||||
|
for i, tok := range config.API.BearerTokens {
|
||||||
|
if tok == "" || tok == "example" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
config.API.bearerTokenBytes[i] = []byte(tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tlsConfig *tls.Config
|
||||||
|
if config.API.TLS.Cert != "" {
|
||||||
|
cert, err := loadCertWithLeaf(config.API.TLS.Cert, config.API.TLS.Key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tlsConfig = &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
// TODO consider supporting client certificates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.API.tlsConfig = tlsConfig
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// LoadRawConfig loads the config without doing any consistency checks or postprocessing
|
// LoadRawConfig loads the config without doing any consistency checks or postprocessing
|
||||||
func LoadRawConfig(filename string) (config *Config, err error) {
|
func LoadRawConfig(filename string) (config *Config, err error) {
|
||||||
data, err := os.ReadFile(filename)
|
data, err := os.ReadFile(filename)
|
||||||
@ -1038,7 +1106,7 @@ func (ce *configPathError) Error() string {
|
|||||||
return fmt.Sprintf("Couldn't apply config override `%s`: %s", ce.name, ce.desc)
|
return fmt.Sprintf("Couldn't apply config override `%s`: %s", ce.name, ce.desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *configPathError) {
|
func mungeFromEnvironment(config *Config, envPair string) (applied bool, name string, err *configPathError) {
|
||||||
equalIdx := strings.IndexByte(envPair, '=')
|
equalIdx := strings.IndexByte(envPair, '=')
|
||||||
name, value := envPair[:equalIdx], envPair[equalIdx+1:]
|
name, value := envPair[:equalIdx], envPair[equalIdx+1:]
|
||||||
if strings.HasPrefix(name, "ERGO__") {
|
if strings.HasPrefix(name, "ERGO__") {
|
||||||
@ -1046,63 +1114,112 @@ 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 {
|
||||||
pathComponents[i] = screamingSnakeToKebab(pathComponent)
|
pathComponents[i] = screamingSnakeToKebab(pathComponent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mapInsertion struct {
|
||||||
|
m reflect.Value
|
||||||
|
k reflect.Value
|
||||||
|
v reflect.Value
|
||||||
|
}
|
||||||
|
var mapStack []mapInsertion
|
||||||
|
|
||||||
v := reflect.Indirect(reflect.ValueOf(config))
|
v := reflect.Indirect(reflect.ValueOf(config))
|
||||||
t := v.Type()
|
t := v.Type()
|
||||||
for _, component := range pathComponents {
|
for _, component := range pathComponents {
|
||||||
if component == "" {
|
if component == "" {
|
||||||
return false, &configPathError{name, "invalid", nil}
|
return false, "", &configPathError{name, "invalid", nil}
|
||||||
}
|
}
|
||||||
if v.Kind() != reflect.Struct {
|
if v.Kind() == reflect.Struct {
|
||||||
return false, &configPathError{name, "index into non-struct", nil}
|
var nextField reflect.StructField
|
||||||
}
|
success := false
|
||||||
var nextField reflect.StructField
|
n := t.NumField()
|
||||||
success := false
|
// preferentially get a field with an exact yaml tag match,
|
||||||
n := t.NumField()
|
// then fall back to case-insensitive comparison of field names
|
||||||
// preferentially get a field with an exact yaml tag match,
|
|
||||||
// then fall back to case-insensitive comparison of field names
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
field := t.Field(i)
|
|
||||||
if isExported(field) && field.Tag.Get("yaml") == component {
|
|
||||||
nextField = field
|
|
||||||
success = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !success {
|
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
field := t.Field(i)
|
field := t.Field(i)
|
||||||
if isExported(field) && strings.ToLower(field.Name) == component {
|
if isExported(field) && field.Tag.Get("yaml") == component {
|
||||||
nextField = field
|
nextField = field
|
||||||
success = true
|
success = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if !success {
|
||||||
if !success {
|
for i := 0; i < n; i++ {
|
||||||
return false, &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil}
|
field := t.Field(i)
|
||||||
}
|
if isExported(field) && strings.ToLower(field.Name) == component {
|
||||||
v = v.FieldByName(nextField.Name)
|
nextField = field
|
||||||
// dereference pointer field if necessary, initialize new value if necessary
|
success = true
|
||||||
if v.Kind() == reflect.Ptr {
|
break
|
||||||
if v.IsNil() {
|
}
|
||||||
v.Set(reflect.New(v.Type().Elem()))
|
}
|
||||||
}
|
}
|
||||||
v = reflect.Indirect(v)
|
if !success {
|
||||||
|
return false, "", &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil}
|
||||||
|
}
|
||||||
|
v = v.FieldByName(nextField.Name)
|
||||||
|
// dereference pointer field if necessary, initialize new value if necessary
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.Ptr:
|
||||||
|
if v.IsNil() {
|
||||||
|
v.Set(reflect.New(v.Type().Elem()))
|
||||||
|
}
|
||||||
|
v = reflect.Indirect(v)
|
||||||
|
case reflect.Map:
|
||||||
|
if v.IsNil() {
|
||||||
|
v.Set(reflect.MakeMap(v.Type()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t = v.Type()
|
||||||
|
} else if v.Kind() == reflect.Map {
|
||||||
|
keyType := v.Type().Key()
|
||||||
|
valueType := v.Type().Elem()
|
||||||
|
if keyType.Kind() != reflect.String {
|
||||||
|
return false, "", &configPathError{name, "can't index into map unless its keys are strings", nil}
|
||||||
|
}
|
||||||
|
// index into the map, returns the zero value (invalid) if not found
|
||||||
|
key := reflect.ValueOf(component)
|
||||||
|
v2 := v.MapIndex(key)
|
||||||
|
if v2.IsValid() {
|
||||||
|
// make an addressable copy of the existing value:
|
||||||
|
v3 := reflect.New(valueType).Elem()
|
||||||
|
v3.Set(v2)
|
||||||
|
v2 = v3
|
||||||
|
} else {
|
||||||
|
// make an addressable value of the map value type:
|
||||||
|
v2 = reflect.New(valueType).Elem()
|
||||||
|
// if the map value type is *Baz, set it to a new(Baz):
|
||||||
|
if valueType.Kind() == reflect.Pointer {
|
||||||
|
v2.Set(reflect.New(valueType.Elem()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// we are not operating directly on the current map member,
|
||||||
|
// we need to go back later and insert v2 into the map:
|
||||||
|
mapStack = append(mapStack, mapInsertion{m: v, k: key, v: v2})
|
||||||
|
if valueType.Kind() != reflect.Pointer {
|
||||||
|
v = v2
|
||||||
|
} else {
|
||||||
|
v = reflect.Indirect(v2)
|
||||||
|
}
|
||||||
|
t = v.Type()
|
||||||
|
} else {
|
||||||
|
return false, "", &configPathError{name, "can't index into fields other than struct or map", nil}
|
||||||
}
|
}
|
||||||
t = v.Type()
|
|
||||||
}
|
}
|
||||||
yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface())
|
yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface())
|
||||||
if yamlErr != nil {
|
if yamlErr != nil {
|
||||||
return false, &configPathError{name, "couldn't deserialize YAML", yamlErr}
|
return false, "", &configPathError{name, "couldn't deserialize YAML", yamlErr}
|
||||||
}
|
}
|
||||||
return true, nil
|
// go back and do all map assignments
|
||||||
|
for i := len(mapStack) - 1; i >= 0; i-- {
|
||||||
|
elem := mapStack[i]
|
||||||
|
elem.m.SetMapIndex(elem.k, elem.v)
|
||||||
|
}
|
||||||
|
return true, name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig loads the given YAML configuration file.
|
// LoadConfig loads the given YAML configuration file.
|
||||||
@ -1114,7 +1231,7 @@ func LoadConfig(filename string) (config *Config, err error) {
|
|||||||
|
|
||||||
if config.AllowEnvironmentOverrides {
|
if config.AllowEnvironmentOverrides {
|
||||||
for _, envPair := range os.Environ() {
|
for _, envPair := range os.Environ() {
|
||||||
applied, envErr := mungeFromEnvironment(config, envPair)
|
applied, name, envErr := mungeFromEnvironment(config, envPair)
|
||||||
if envErr != nil {
|
if envErr != nil {
|
||||||
if envErr.fatalErr != nil {
|
if envErr.fatalErr != nil {
|
||||||
return nil, envErr
|
return nil, envErr
|
||||||
@ -1122,7 +1239,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1156,10 +1273,59 @@ func LoadConfig(filename string) (config *Config, err error) {
|
|||||||
config.Server.MaxLineLen = DefaultMaxLineLen
|
config.Server.MaxLineLen = DefaultMaxLineLen
|
||||||
}
|
}
|
||||||
if config.Datastore.MySQL.Enabled {
|
if config.Datastore.MySQL.Enabled {
|
||||||
|
if !mysql.Enabled {
|
||||||
|
return nil, fmt.Errorf("MySQL is enabled in the config, but this binary was not built with MySQL support. Rebuild with `make build_full` to enable")
|
||||||
|
}
|
||||||
if config.Limits.NickLen > mysql.MaxTargetLength || config.Limits.ChannelLen > mysql.MaxTargetLength {
|
if config.Limits.NickLen > mysql.MaxTargetLength || config.Limits.ChannelLen > mysql.MaxTargetLength {
|
||||||
return nil, fmt.Errorf("to use MySQL, nick and channel length limits must be %d or lower", mysql.MaxTargetLength)
|
return nil, fmt.Errorf("to use MySQL, nick and channel length limits must be %d or lower", mysql.MaxTargetLength)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if config.Datastore.PostgreSQL.Enabled {
|
||||||
|
if !postgresql.Enabled {
|
||||||
|
return nil, fmt.Errorf("PostgreSQL is enabled in the config, but this binary was not built with PostgreSQL support. Rebuild with `make build_full` to enable")
|
||||||
|
}
|
||||||
|
if config.Limits.NickLen > postgresql.MaxTargetLength || config.Limits.ChannelLen > postgresql.MaxTargetLength {
|
||||||
|
return nil, fmt.Errorf("to use PostgreSQL, nick and channel length limits must be %d or lower", postgresql.MaxTargetLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.Datastore.SQLite.Enabled {
|
||||||
|
if !sqlite.Enabled {
|
||||||
|
return nil, fmt.Errorf("SQLite is enabled in the config, but this binary was not built with SQLite support. Rebuild with `make build_full` to enable")
|
||||||
|
}
|
||||||
|
if config.Limits.NickLen > sqlite.MaxTargetLength || config.Limits.ChannelLen > sqlite.MaxTargetLength {
|
||||||
|
return nil, fmt.Errorf("to use SQLite, nick and channel length limits must be %d or lower", sqlite.MaxTargetLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enabledBackends := 0
|
||||||
|
if config.Datastore.MySQL.Enabled {
|
||||||
|
enabledBackends++
|
||||||
|
}
|
||||||
|
if config.Datastore.PostgreSQL.Enabled {
|
||||||
|
enabledBackends++
|
||||||
|
}
|
||||||
|
if config.Datastore.SQLite.Enabled {
|
||||||
|
enabledBackends++
|
||||||
|
}
|
||||||
|
if enabledBackends > 1 {
|
||||||
|
return nil, fmt.Errorf("cannot enable multiple history database backends simultaneously")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Server.IdleTimeouts.Registration <= 0 {
|
||||||
|
config.Server.IdleTimeouts.Registration = time.Minute
|
||||||
|
}
|
||||||
|
if config.Server.IdleTimeouts.Ping <= 0 {
|
||||||
|
config.Server.IdleTimeouts.Ping = time.Minute + 30*time.Second
|
||||||
|
}
|
||||||
|
if config.Server.IdleTimeouts.Disconnect <= 0 {
|
||||||
|
config.Server.IdleTimeouts.Disconnect = 2*time.Minute + 30*time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(config.Server.IdleTimeouts.Ping < config.Server.IdleTimeouts.Disconnect) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"ping timeout %v must be strictly less than disconnect timeout %v, to give the client time to respond",
|
||||||
|
config.Server.IdleTimeouts.Ping, config.Server.IdleTimeouts.Disconnect,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if config.Server.CoerceIdent != "" {
|
if config.Server.CoerceIdent != "" {
|
||||||
if config.Server.CheckIdent {
|
if config.Server.CheckIdent {
|
||||||
@ -1237,6 +1403,12 @@ func LoadConfig(filename string) (config *Config, err error) {
|
|||||||
config.Server.capValues[caps.Multiline] = multilineCapValue
|
config.Server.capValues[caps.Multiline] = multilineCapValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !i18n.Enabled {
|
||||||
|
if config.Server.Casemapping != i18n.CasemappingASCII {
|
||||||
|
return nil, fmt.Errorf("i18n support was compiled out; set casemapping to 'ascii' or recompile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handle legacy name 'bouncer' for 'multiclient' section:
|
// handle legacy name 'bouncer' for 'multiclient' section:
|
||||||
if config.Accounts.Bouncer != nil {
|
if config.Accounts.Bouncer != nil {
|
||||||
config.Accounts.Multiclient = *config.Accounts.Bouncer
|
config.Accounts.Multiclient = *config.Accounts.Bouncer
|
||||||
@ -1389,16 +1561,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 {
|
||||||
// TODO(#1782) clean this up:
|
saslCapValues := []string{"PLAIN", "EXTERNAL"}
|
||||||
if !config.Accounts.AdvertiseSCRAM {
|
if config.Accounts.AdvertiseSCRAM {
|
||||||
saslCapValue = "PLAIN,EXTERNAL"
|
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 {
|
||||||
@ -1470,6 +1664,12 @@ func LoadConfig(filename string) (config *Config, err error) {
|
|||||||
if config.Accounts.Registration.BcryptCost == 0 {
|
if config.Accounts.Registration.BcryptCost == 0 {
|
||||||
config.Accounts.Registration.BcryptCost = passwd.DefaultCost
|
config.Accounts.Registration.BcryptCost = passwd.DefaultCost
|
||||||
}
|
}
|
||||||
|
if config.Accounts.Registration.BcryptCost < passwd.MinCost || config.Accounts.Registration.BcryptCost > passwd.MaxCost {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"invalid bcrypt-cost %d (require %d <= cost <= %d)",
|
||||||
|
config.Accounts.Registration.BcryptCost, passwd.MinCost, passwd.MaxCost,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if config.Channels.MaxChannelsPerClient == 0 {
|
if config.Channels.MaxChannelsPerClient == 0 {
|
||||||
config.Channels.MaxChannelsPerClient = 100
|
config.Channels.MaxChannelsPerClient = 100
|
||||||
@ -1486,12 +1686,13 @@ func LoadConfig(filename string) (config *Config, err error) {
|
|||||||
// in the current implementation, we disable history by creating a history buffer
|
// in the current implementation, we disable history by creating a history buffer
|
||||||
// with zero capacity. but the `enabled` config option MUST be respected regardless
|
// with zero capacity. but the `enabled` config option MUST be respected regardless
|
||||||
// of this detail
|
// of this detail
|
||||||
if !config.History.Enabled {
|
if !config.History.Enabled || config.History.ChathistoryMax == 0 {
|
||||||
config.History.ChannelLength = 0
|
config.History.ChannelLength = 0
|
||||||
config.History.ClientLength = 0
|
config.History.ClientLength = 0
|
||||||
config.Server.supportedCaps.Disable(caps.Chathistory)
|
config.Server.supportedCaps.Disable(caps.Chathistory)
|
||||||
config.Server.supportedCaps.Disable(caps.EventPlayback)
|
config.Server.supportedCaps.Disable(caps.EventPlayback)
|
||||||
config.Server.supportedCaps.Disable(caps.ZNCPlayback)
|
config.Server.supportedCaps.Disable(caps.ZNCPlayback)
|
||||||
|
config.Server.supportedCaps.Disable(caps.MessageRedaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !config.History.Enabled || !config.History.Persistent.Enabled {
|
if !config.History.Enabled || !config.History.Persistent.Enabled {
|
||||||
@ -1501,8 +1702,8 @@ func LoadConfig(filename string) (config *Config, err error) {
|
|||||||
config.History.Persistent.DirectMessages = PersistentDisabled
|
config.History.Persistent.DirectMessages = PersistentDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.History.Persistent.Enabled && !config.Datastore.MySQL.Enabled {
|
if config.History.Persistent.Enabled && !config.Datastore.MySQL.Enabled && !config.Datastore.PostgreSQL.Enabled && !config.Datastore.SQLite.Enabled {
|
||||||
return nil, fmt.Errorf("You must configure a MySQL server in order to enable persistent history")
|
return nil, fmt.Errorf("You must configure a MySQL, PostgreSQL, or SQLite database in order to enable persistent history")
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.History.ZNCMax == 0 {
|
if config.History.ZNCMax == 0 {
|
||||||
@ -1522,7 +1723,17 @@ func LoadConfig(filename string) (config *Config, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !config.History.Retention.AllowIndividualDelete {
|
||||||
|
config.Server.supportedCaps.Disable(caps.MessageRedaction) // #2215
|
||||||
|
}
|
||||||
|
|
||||||
config.Roleplay.addSuffix = utils.BoolDefaultTrue(config.Roleplay.AddSuffix)
|
config.Roleplay.addSuffix = utils.BoolDefaultTrue(config.Roleplay.AddSuffix)
|
||||||
|
if config.Roleplay.NPCNickMask == "" {
|
||||||
|
config.Roleplay.NPCNickMask = defaultNPCNickMask
|
||||||
|
}
|
||||||
|
if config.Roleplay.SceneNickMask == "" {
|
||||||
|
config.Roleplay.SceneNickMask = defaultSceneNickMask
|
||||||
|
}
|
||||||
|
|
||||||
config.Datastore.MySQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
|
config.Datastore.MySQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
|
||||||
config.Datastore.MySQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
|
config.Datastore.MySQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
|
||||||
@ -1532,6 +1743,15 @@ func LoadConfig(filename string) (config *Config, err error) {
|
|||||||
// same machine:
|
// same machine:
|
||||||
config.Datastore.MySQL.MaxConns = runtime.NumCPU()
|
config.Datastore.MySQL.MaxConns = runtime.NumCPU()
|
||||||
}
|
}
|
||||||
|
// do the same for postgresql
|
||||||
|
config.Datastore.PostgreSQL.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
|
||||||
|
config.Datastore.PostgreSQL.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
|
||||||
|
if config.Datastore.PostgreSQL.MaxConns == 0 {
|
||||||
|
config.Datastore.PostgreSQL.MaxConns = runtime.NumCPU()
|
||||||
|
}
|
||||||
|
// and for sqlite
|
||||||
|
config.Datastore.SQLite.ExpireTime = time.Duration(config.History.Restrictions.ExpireTime)
|
||||||
|
config.Datastore.SQLite.TrackAccountMessages = config.History.Retention.EnableAccountIndexing
|
||||||
|
|
||||||
config.Server.Cloaks.Initialize()
|
config.Server.Cloaks.Initialize()
|
||||||
if config.Server.Cloaks.Enabled {
|
if config.Server.Cloaks.Enabled {
|
||||||
@ -1540,11 +1760,65 @@ func LoadConfig(filename string) (config *Config, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !config.Metadata.Enabled {
|
||||||
|
config.Server.supportedCaps.Disable(caps.Metadata)
|
||||||
|
} else {
|
||||||
|
metadataValues := make([]string, 0, 4)
|
||||||
|
metadataValues = append(metadataValues, "before-connect")
|
||||||
|
// these are required for normal operation, so set sane defaults:
|
||||||
|
if config.Metadata.MaxSubs == 0 {
|
||||||
|
config.Metadata.MaxSubs = 10
|
||||||
|
}
|
||||||
|
metadataValues = append(metadataValues, fmt.Sprintf("max-subs=%d", config.Metadata.MaxSubs))
|
||||||
|
if config.Metadata.MaxKeys == 0 {
|
||||||
|
config.Metadata.MaxKeys = 10
|
||||||
|
}
|
||||||
|
metadataValues = append(metadataValues, fmt.Sprintf("max-keys=%d", config.Metadata.MaxKeys))
|
||||||
|
// this is not required since we enforce a hardcoded upper bound on key+value
|
||||||
|
if config.Metadata.MaxValueBytes > 0 {
|
||||||
|
metadataValues = append(metadataValues, fmt.Sprintf("max-value-bytes=%d", config.Metadata.MaxValueBytes))
|
||||||
|
}
|
||||||
|
config.Server.capValues[caps.Metadata] = strings.Join(metadataValues, ",")
|
||||||
|
}
|
||||||
|
|
||||||
err = config.processExtjwt()
|
err = config.processExtjwt()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.WebPush.Enabled {
|
||||||
|
if config.Accounts.Multiclient.AlwaysOn == PersistentDisabled {
|
||||||
|
return nil, fmt.Errorf("Cannot enable webpush if always-on is disabled")
|
||||||
|
}
|
||||||
|
if config.WebPush.Timeout == 0 {
|
||||||
|
config.WebPush.Timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
if config.WebPush.Subscriber == "" {
|
||||||
|
config.WebPush.Subscriber = "https://ergo.chat/about"
|
||||||
|
}
|
||||||
|
if config.WebPush.MaxSubscriptions <= 0 {
|
||||||
|
config.WebPush.MaxSubscriptions = 1
|
||||||
|
}
|
||||||
|
if config.WebPush.Expiration == 0 {
|
||||||
|
config.WebPush.Expiration = custime.Duration(14 * 24 * time.Hour)
|
||||||
|
} else if config.WebPush.Expiration < custime.Duration(3*24*time.Hour) {
|
||||||
|
return nil, fmt.Errorf("webpush.expiration is too short (should be several days)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config.Server.supportedCaps.Disable(caps.WebPush)
|
||||||
|
config.Server.supportedCaps.Disable(caps.SojuWebPush)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = config.processAPI()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Server.CommandAliases, err = normalizeCommandAliases(config.Server.CommandAliases)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// now that all postprocessing is complete, regenerate ISUPPORT:
|
// now that all postprocessing is complete, regenerate ISUPPORT:
|
||||||
err = config.generateISupport()
|
err = config.generateISupport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1568,6 +1842,10 @@ func (config *Config) isRelaymsgIdentifier(nick string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(nick, "#") {
|
||||||
|
return false // #2114
|
||||||
|
}
|
||||||
|
|
||||||
for _, char := range config.Server.Relaymsg.Separators {
|
for _, char := range config.Server.Relaymsg.Separators {
|
||||||
if strings.ContainsRune(nick, char) {
|
if strings.ContainsRune(nick, char) {
|
||||||
return true
|
return true
|
||||||
@ -1585,9 +1863,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 i18n.CasemappingRFC1459:
|
||||||
|
casemappingToken = "rfc1459"
|
||||||
|
case i18n.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:
|
||||||
@ -1606,6 +1893,7 @@ func (config *Config) generateISupport() (err error) {
|
|||||||
isupport.Add("KICKLEN", strconv.Itoa(config.Limits.KickLen))
|
isupport.Add("KICKLEN", strconv.Itoa(config.Limits.KickLen))
|
||||||
isupport.Add("MAXLIST", fmt.Sprintf("beI:%s", strconv.Itoa(config.Limits.ChanListModes)))
|
isupport.Add("MAXLIST", fmt.Sprintf("beI:%s", strconv.Itoa(config.Limits.ChanListModes)))
|
||||||
isupport.Add("MAXTARGETS", maxTargetsString)
|
isupport.Add("MAXTARGETS", maxTargetsString)
|
||||||
|
isupport.Add("MSGREFTYPES", "msgid,timestamp")
|
||||||
isupport.Add("MODES", "")
|
isupport.Add("MODES", "")
|
||||||
isupport.Add("MONITOR", strconv.Itoa(config.Limits.MonitorEntries))
|
isupport.Add("MONITOR", strconv.Itoa(config.Limits.MonitorEntries))
|
||||||
isupport.Add("NETWORK", config.Network.Name)
|
isupport.Add("NETWORK", config.Network.Name)
|
||||||
@ -1615,17 +1903,36 @@ func (config *Config) generateISupport() (err error) {
|
|||||||
isupport.Add("RPCHAN", "E")
|
isupport.Add("RPCHAN", "E")
|
||||||
isupport.Add("RPUSER", "E")
|
isupport.Add("RPUSER", "E")
|
||||||
}
|
}
|
||||||
|
isupport.Add("SAFELIST", "")
|
||||||
|
isupport.Add("SAFERATE", "")
|
||||||
isupport.Add("STATUSMSG", "~&@%+")
|
isupport.Add("STATUSMSG", "~&@%+")
|
||||||
isupport.Add("TARGMAX", fmt.Sprintf("NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:%s,TAGMSG:%s,NOTICE:%s,MONITOR:%d", maxTargetsString, maxTargetsString, maxTargetsString, config.Limits.MonitorEntries))
|
isupport.Add("TARGMAX", fmt.Sprintf("NAMES:1,LIST:1,KICK:,WHOIS:1,USERHOST:10,PRIVMSG:%s,TAGMSG:%s,NOTICE:%s,MONITOR:%d", maxTargetsString, maxTargetsString, maxTargetsString, config.Limits.MonitorEntries))
|
||||||
isupport.Add("TOPICLEN", strconv.Itoa(config.Limits.TopicLen))
|
isupport.Add("TOPICLEN", strconv.Itoa(config.Limits.TopicLen))
|
||||||
if config.Server.Casemapping == CasemappingPRECIS {
|
if config.Server.Casemapping == i18n.CasemappingPRECIS {
|
||||||
isupport.Add("UTF8MAPPING", precisUTF8MappingToken)
|
isupport.Add("UTF8MAPPING", precisUTF8MappingToken)
|
||||||
}
|
}
|
||||||
if config.Server.EnforceUtf8 {
|
if config.Server.EnforceUtf8 {
|
||||||
isupport.Add("UTF8ONLY", "")
|
isupport.Add("UTF8ONLY", "")
|
||||||
}
|
}
|
||||||
|
if config.WebPush.Enabled {
|
||||||
|
// XXX we typically don't have this at config parse time, so we'll have to regenerate
|
||||||
|
// the cached reply later
|
||||||
|
if config.WebPush.vapidKeys != nil {
|
||||||
|
isupport.Add("VAPID", config.WebPush.vapidKeys.PublicKeyString())
|
||||||
|
}
|
||||||
|
}
|
||||||
isupport.Add("WHOX", "")
|
isupport.Add("WHOX", "")
|
||||||
|
|
||||||
|
if config.Accounts.RequireSasl.Enabled {
|
||||||
|
isupport.Add("draft/ACCOUNTREQUIRED", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range config.Server.AdditionalISupport {
|
||||||
|
if !isupport.Contains(key) {
|
||||||
|
isupport.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = isupport.RegenerateCachedReply()
|
err = isupport.RegenerateCachedReply()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1678,7 +1985,7 @@ func (config *Config) historyChangedFrom(oldConfig *Config) bool {
|
|||||||
config.History.Persistent != oldConfig.History.Persistent
|
config.History.Persistent != oldConfig.History.Persistent
|
||||||
}
|
}
|
||||||
|
|
||||||
func compileGuestRegexp(guestFormat string, casemapping Casemapping) (standard, folded *regexp.Regexp, err error) {
|
func compileGuestRegexp(guestFormat string, casemapping i18n.Casemapping) (standard, folded *regexp.Regexp, err error) {
|
||||||
if strings.Count(guestFormat, "?") != 0 || strings.Count(guestFormat, "*") != 1 {
|
if strings.Count(guestFormat, "?") != 0 || strings.Count(guestFormat, "*") != 1 {
|
||||||
err = errors.New("guest format must contain 1 '*' and no '?'s")
|
err = errors.New("guest format must contain 1 '*' and no '?'s")
|
||||||
return
|
return
|
||||||
@ -1692,11 +1999,11 @@ func compileGuestRegexp(guestFormat string, casemapping Casemapping) (standard,
|
|||||||
starIndex := strings.IndexByte(guestFormat, '*')
|
starIndex := strings.IndexByte(guestFormat, '*')
|
||||||
initial := guestFormat[:starIndex]
|
initial := guestFormat[:starIndex]
|
||||||
final := guestFormat[starIndex+1:]
|
final := guestFormat[starIndex+1:]
|
||||||
initialFolded, err := casefoldWithSetting(initial, casemapping)
|
initialFolded, err := i18n.CasefoldWithSetting(initial, casemapping)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
finalFolded, err := casefoldWithSetting(final, casemapping)
|
finalFolded, err := i18n.CasefoldWithSetting(final, casemapping)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1727,6 +2034,9 @@ func (config *Config) loadMOTD() error {
|
|||||||
if config.Server.MOTDFormatting {
|
if config.Server.MOTDFormatting {
|
||||||
lineToSend = ircfmt.Unescape(lineToSend)
|
lineToSend = ircfmt.Unescape(lineToSend)
|
||||||
}
|
}
|
||||||
|
if config.Server.EnforceUtf8 && !utf8.ValidString(lineToSend) {
|
||||||
|
return fmt.Errorf("Line %d of MOTD contains invalid UTF8", i+1)
|
||||||
|
}
|
||||||
// "- " is the required prefix for MOTD
|
// "- " is the required prefix for MOTD
|
||||||
lineToSend = fmt.Sprintf("- %s", lineToSend)
|
lineToSend = fmt.Sprintf("- %s", lineToSend)
|
||||||
config.Server.motdLines = append(config.Server.motdLines, lineToSend)
|
config.Server.motdLines = append(config.Server.motdLines, lineToSend)
|
||||||
@ -1734,3 +2044,22 @@ func (config *Config) loadMOTD() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeCommandAliases(aliases map[string]string) (normalizedAliases map[string]string, err error) {
|
||||||
|
if len(aliases) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
normalizedAliases = make(map[string]string, len(aliases))
|
||||||
|
for alias, command := range aliases {
|
||||||
|
alias = strings.ToUpper(alias)
|
||||||
|
command = strings.ToUpper(command)
|
||||||
|
if _, found := Commands[alias]; found {
|
||||||
|
return nil, fmt.Errorf("Command alias `%s` collides with a real Ergo command", alias)
|
||||||
|
}
|
||||||
|
if _, found := Commands[command]; !found {
|
||||||
|
return nil, fmt.Errorf("Command alias `%s` mapped to non-existent Ergo command `%s`", alias, command)
|
||||||
|
}
|
||||||
|
normalizedAliases[alias] = command
|
||||||
|
}
|
||||||
|
return normalizedAliases, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,15 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func mungeEnvForTesting(config *Config, env []string, t *testing.T) {
|
||||||
|
for _, envPair := range env {
|
||||||
|
_, _, err := mungeFromEnvironment(config, envPair)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("couldn't apply override `%s`: %v", envPair, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnvironmentOverrides(t *testing.T) {
|
func TestEnvironmentOverrides(t *testing.T) {
|
||||||
var config Config
|
var config Config
|
||||||
config.Server.Compatibility.SendUnprefixedSasl = true
|
config.Server.Compatibility.SendUnprefixedSasl = true
|
||||||
@ -16,6 +25,12 @@ func TestEnvironmentOverrides(t *testing.T) {
|
|||||||
config.Accounts.DefaultUserModes = &defaultUserModes
|
config.Accounts.DefaultUserModes = &defaultUserModes
|
||||||
config.Server.WebSockets.AllowedOrigins = []string{"https://www.ircv3.net"}
|
config.Server.WebSockets.AllowedOrigins = []string{"https://www.ircv3.net"}
|
||||||
config.Server.MOTD = "long.motd.txt" // overwrite this
|
config.Server.MOTD = "long.motd.txt" // overwrite this
|
||||||
|
config.Opers = map[string]*OperConfig{
|
||||||
|
"admin": {
|
||||||
|
Class: "server-admin",
|
||||||
|
Password: "adminpassword",
|
||||||
|
},
|
||||||
|
}
|
||||||
env := []string{
|
env := []string{
|
||||||
`USER=shivaram`, // unrelated var
|
`USER=shivaram`, // unrelated var
|
||||||
`ORAGONO_USER=oragono`, // this should be ignored as well
|
`ORAGONO_USER=oragono`, // this should be ignored as well
|
||||||
@ -26,13 +41,11 @@ func TestEnvironmentOverrides(t *testing.T) {
|
|||||||
`ORAGONO__ACCOUNTS__NICK_RESERVATION__ENABLED=true`,
|
`ORAGONO__ACCOUNTS__NICK_RESERVATION__ENABLED=true`,
|
||||||
`ERGO__ACCOUNTS__DEFAULT_USER_MODES="+iR"`,
|
`ERGO__ACCOUNTS__DEFAULT_USER_MODES="+iR"`,
|
||||||
`ORAGONO__SERVER__IP_CLOAKING={"enabled": true, "enabled-for-always-on": true, "netname": "irc", "cidr-len-ipv4": 32, "cidr-len-ipv6": 64, "num-bits": 64}`,
|
`ORAGONO__SERVER__IP_CLOAKING={"enabled": true, "enabled-for-always-on": true, "netname": "irc", "cidr-len-ipv4": 32, "cidr-len-ipv6": 64, "num-bits": 64}`,
|
||||||
|
`ERGO__OPERS__ADMIN__PASSWORD="newadminpassword"`,
|
||||||
|
`ERGO__OPERS__OPERUSER={"class": "server-admin", "whois-line": "is a server admin", "password": "operpassword"}`,
|
||||||
}
|
}
|
||||||
for _, envPair := range env {
|
|
||||||
_, err := mungeFromEnvironment(&config, envPair)
|
mungeEnvForTesting(&config, env, t)
|
||||||
if err != nil {
|
|
||||||
t.Errorf("couldn't apply override `%s`: %v", envPair, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.Network.Name != "example.com" {
|
if config.Network.Name != "example.com" {
|
||||||
t.Errorf("unexpected value of network.name: %s", config.Network.Name)
|
t.Errorf("unexpected value of network.name: %s", config.Network.Name)
|
||||||
@ -68,6 +81,56 @@ func TestEnvironmentOverrides(t *testing.T) {
|
|||||||
if *config.Accounts.DefaultUserModes != "+iR" {
|
if *config.Accounts.DefaultUserModes != "+iR" {
|
||||||
t.Errorf("couldn't override pre-set ptr field")
|
t.Errorf("couldn't override pre-set ptr field")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (*config.Opers["admin"]).Password != "newadminpassword" {
|
||||||
|
t.Errorf("couldn't index into map and then overwrite")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (*config.Opers["operuser"]).Password != "operpassword" {
|
||||||
|
t.Errorf("couldn't create new entry in map")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvironmentInitializeNilMap(t *testing.T) {
|
||||||
|
var config Config
|
||||||
|
env := []string{
|
||||||
|
`ERGO__OPERS__OPERUSER={"class": "server-admin", "whois-line": "is a server admin", "password": "operpassword"}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
mungeEnvForTesting(&config, env, t)
|
||||||
|
assertEqual((*config.Opers["operuser"]).Password, "operpassword")
|
||||||
|
|
||||||
|
// try with an initialized but empty map:
|
||||||
|
config.Opers = make(map[string]*OperConfig)
|
||||||
|
mungeEnvForTesting(&config, env, t)
|
||||||
|
assertEqual((*config.Opers["operuser"]).Password, "operpassword")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvironmentCreateNewMap(t *testing.T) {
|
||||||
|
var config Config
|
||||||
|
env := []string{
|
||||||
|
`ERGO__OPERS={"operuser": {"class": "server-admin", "whois-line": "is a server admin", "password": "operpassword"}}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
mungeEnvForTesting(&config, env, t)
|
||||||
|
assertEqual((*config.Opers["operuser"]).Password, "operpassword")
|
||||||
|
|
||||||
|
// try with an initialized but empty map:
|
||||||
|
config.Opers = make(map[string]*OperConfig)
|
||||||
|
mungeEnvForTesting(&config, env, t)
|
||||||
|
assertEqual((*config.Opers["operuser"]).Password, "operpassword")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvironmentNonPointerMap(t *testing.T) {
|
||||||
|
// edge cases that should not panic, even though the results are unusable
|
||||||
|
// since all "field names" get lowercased:
|
||||||
|
var config Config
|
||||||
|
config.Server.AdditionalISupport = map[string]string{"extban": "a"}
|
||||||
|
env := []string{
|
||||||
|
`ERGO__SERVER__ADDITIONAL_ISUPPORT__EXTBAN=~,a`,
|
||||||
|
`ERGO__FAKELAG__COMMAND_BUDGETS__PRIVMSG=10`,
|
||||||
|
}
|
||||||
|
mungeEnvForTesting(&config, env, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnvironmentOverrideErrors(t *testing.T) {
|
func TestEnvironmentOverrideErrors(t *testing.T) {
|
||||||
@ -76,24 +139,24 @@ func TestEnvironmentOverrideErrors(t *testing.T) {
|
|||||||
config.History.Enabled = true
|
config.History.Enabled = true
|
||||||
|
|
||||||
invalidEnvs := []string{
|
invalidEnvs := []string{
|
||||||
`ORAGONO__=asdf`,
|
`ERGO__=asdf`,
|
||||||
`ORAGONO__SERVER__=asdf`,
|
`ERGO__SERVER__=asdf`,
|
||||||
`ORAGONO__SERVER____=asdf`,
|
`ERGO__SERVER____=asdf`,
|
||||||
`ORAGONO__NONEXISTENT_KEY=1`,
|
`ERGO__NONEXISTENT_KEY=1`,
|
||||||
`ORAGONO__SERVER__NONEXISTENT_KEY=1`,
|
`ERGO__SERVER__NONEXISTENT_KEY=1`,
|
||||||
// invalid yaml:
|
// invalid yaml:
|
||||||
`ORAGONO__SERVER__IP_CLOAKING__NETNAME="`,
|
`ERGO__SERVER__IP_CLOAKING__NETNAME="`,
|
||||||
// invalid type:
|
// invalid type:
|
||||||
`ORAGONO__SERVER__IP_CLOAKING__NUM_BITS=asdf`,
|
`ERGO__SERVER__IP_CLOAKING__NUM_BITS=asdf`,
|
||||||
`ORAGONO__SERVER__STS=[]`,
|
`ERGO__SERVER__STS=[]`,
|
||||||
// index into non-struct:
|
// index into non-struct:
|
||||||
`ORAGONO__NETWORK__NAME__QUX=1`,
|
`ERGO__NETWORK__NAME__QUX=1`,
|
||||||
// private field:
|
// private field:
|
||||||
`ORAGONO__SERVER__PASSWORDBYTES="asdf"`,
|
`ERGO__SERVER__PASSWORDBYTES="asdf"`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, env := range invalidEnvs {
|
for _, env := range invalidEnvs {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
202
irc/database.go
202
irc/database.go
@ -14,19 +14,31 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/bunt"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// 'version' of the database schema
|
// TODO migrate metadata keys as well
|
||||||
keySchemaVersion = "db.version"
|
|
||||||
// latest schema of the db
|
|
||||||
latestDbSchema = 22
|
|
||||||
|
|
||||||
keyCloakSecret = "crypto.cloak_secret"
|
// 'version' of the database schema
|
||||||
|
// latest schema of the db
|
||||||
|
latestDbSchema = 24
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
schemaVersionUUID = utils.UUID{0, 255, 85, 13, 212, 10, 191, 121, 245, 152, 142, 89, 97, 141, 219, 87} // AP9VDdQKv3n1mI5ZYY3bVw
|
||||||
|
cloakSecretUUID = utils.UUID{170, 214, 184, 208, 116, 181, 67, 75, 161, 23, 233, 16, 113, 251, 94, 229} // qta40HS1Q0uhF-kQcfte5Q
|
||||||
|
vapidKeysUUID = utils.UUID{87, 215, 189, 5, 65, 105, 249, 44, 65, 96, 170, 56, 187, 110, 12, 235} // V9e9BUFp-SxBYKo4u24M6w
|
||||||
|
|
||||||
|
keySchemaVersion = bunt.BuntKey(datastore.TableMetadata, schemaVersionUUID)
|
||||||
|
keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID)
|
||||||
|
keyVAPIDKeys = bunt.BuntKey(datastore.TableMetadata, vapidKeysUUID)
|
||||||
)
|
)
|
||||||
|
|
||||||
type SchemaChanger func(*Config, *buntdb.Tx) error
|
type SchemaChanger func(*Config, *buntdb.Tx) error
|
||||||
@ -71,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
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -99,10 +120,7 @@ func openDatabaseInternal(config *Config, allowAutoupgrade bool) (db *buntdb.DB,
|
|||||||
// read the current version string
|
// read the current version string
|
||||||
var version int
|
var version int
|
||||||
err = db.View(func(tx *buntdb.Tx) (err error) {
|
err = db.View(func(tx *buntdb.Tx) (err error) {
|
||||||
vStr, err := tx.Get(keySchemaVersion)
|
version, err = retrieveSchemaVersion(tx)
|
||||||
if err == nil {
|
|
||||||
version, err = strconv.Atoi(vStr)
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -130,10 +148,21 @@ func openDatabaseInternal(config *Config, allowAutoupgrade bool) (db *buntdb.DB,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func retrieveSchemaVersion(tx *buntdb.Tx) (version int, err error) {
|
||||||
|
if val, err := tx.Get(keySchemaVersion); err == nil {
|
||||||
|
return strconv.Atoi(val)
|
||||||
|
}
|
||||||
|
// legacy key:
|
||||||
|
if val, err := tx.Get("db.version"); err == nil {
|
||||||
|
return strconv.Atoi(val)
|
||||||
|
}
|
||||||
|
return 0, buntdb.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func performAutoUpgrade(currentVersion int, config *Config) (err error) {
|
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)
|
||||||
@ -167,8 +196,12 @@ func UpgradeDB(config *Config) (err error) {
|
|||||||
var version int
|
var version int
|
||||||
err = store.Update(func(tx *buntdb.Tx) error {
|
err = store.Update(func(tx *buntdb.Tx) error {
|
||||||
for {
|
for {
|
||||||
vStr, _ := tx.Get(keySchemaVersion)
|
if version == 0 {
|
||||||
version, _ = strconv.Atoi(vStr)
|
version, err = retrieveSchemaVersion(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if version == latestDbSchema {
|
if version == latestDbSchema {
|
||||||
// success!
|
// success!
|
||||||
break
|
break
|
||||||
@ -183,11 +216,12 @@ func UpgradeDB(config *Config) (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, _, err = tx.Set(keySchemaVersion, strconv.Itoa(change.TargetVersion), nil)
|
version = change.TargetVersion
|
||||||
|
_, _, err = tx.Set(keySchemaVersion, strconv.Itoa(version), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Printf("successfully updated schema to version %d\n", change.TargetVersion)
|
log.Printf("successfully updated schema to version %d\n", version)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@ -198,19 +232,27 @@ func UpgradeDB(config *Config) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadCloakSecret(db *buntdb.DB) (result string) {
|
func LoadCloakSecret(dstore datastore.Datastore) (result string, err error) {
|
||||||
db.View(func(tx *buntdb.Tx) error {
|
val, err := dstore.Get(datastore.TableMetadata, cloakSecretUUID)
|
||||||
result, _ = tx.Get(keyCloakSecret)
|
if err != nil {
|
||||||
return nil
|
return
|
||||||
})
|
}
|
||||||
return
|
return string(val), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func StoreCloakSecret(db *buntdb.DB, secret string) {
|
func StoreCloakSecret(dstore datastore.Datastore, secret string) {
|
||||||
db.Update(func(tx *buntdb.Tx) error {
|
// TODO error checking
|
||||||
tx.Set(keyCloakSecret, secret, nil)
|
dstore.Set(datastore.TableMetadata, cloakSecretUUID, []byte(secret), time.Time{})
|
||||||
return nil
|
}
|
||||||
})
|
|
||||||
|
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 {
|
||||||
@ -1112,6 +1154,106 @@ func schemaChangeV21To22(config *Config, tx *buntdb.Tx) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// first phase of document-oriented database refactor: channels
|
||||||
|
func schemaChangeV22ToV23(config *Config, tx *buntdb.Tx) error {
|
||||||
|
keyChannelExists := "channel.exists "
|
||||||
|
var channelNames []string
|
||||||
|
tx.AscendGreaterOrEqual("", keyChannelExists, func(key, value string) bool {
|
||||||
|
if !strings.HasPrefix(key, keyChannelExists) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
channelNames = append(channelNames, strings.TrimPrefix(key, keyChannelExists))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
for _, channelName := range channelNames {
|
||||||
|
channel, err := loadLegacyChannel(tx, channelName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error loading legacy channel %s: %v", channelName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
channel.UUID = utils.GenerateUUIDv4()
|
||||||
|
newKey := bunt.BuntKey(datastore.TableChannels, channel.UUID)
|
||||||
|
j, err := json.Marshal(channel)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error marshaling channel %s: %v", channelName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tx.Set(newKey, string(j), nil)
|
||||||
|
deleteLegacyChannel(tx, channelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// purges
|
||||||
|
keyChannelPurged := "channel.purged "
|
||||||
|
var purgeKeys []string
|
||||||
|
var channelPurges []ChannelPurgeRecord
|
||||||
|
tx.AscendGreaterOrEqual("", keyChannelPurged, func(key, value string) bool {
|
||||||
|
if !strings.HasPrefix(key, keyChannelPurged) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
purgeKeys = append(purgeKeys, key)
|
||||||
|
cfname := strings.TrimPrefix(key, keyChannelPurged)
|
||||||
|
var record ChannelPurgeRecord
|
||||||
|
err := json.Unmarshal([]byte(value), &record)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error unmarshaling channel purge for %s: %v", cfname, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
record.NameCasefolded = cfname
|
||||||
|
record.UUID = utils.GenerateUUIDv4()
|
||||||
|
channelPurges = append(channelPurges, record)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
for _, record := range channelPurges {
|
||||||
|
newKey := bunt.BuntKey(datastore.TableChannelPurges, record.UUID)
|
||||||
|
j, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error marshaling channel purge %s: %v", record.NameCasefolded, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tx.Set(newKey, string(j), nil)
|
||||||
|
}
|
||||||
|
for _, purgeKey := range purgeKeys {
|
||||||
|
tx.Delete(purgeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean up denormalized account-to-channels mapping
|
||||||
|
keyAccountChannels := "account.channels "
|
||||||
|
var accountToChannels []string
|
||||||
|
tx.AscendGreaterOrEqual("", keyAccountChannels, func(key, value string) bool {
|
||||||
|
if !strings.HasPrefix(key, keyAccountChannels) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
accountToChannels = append(accountToChannels, key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
for _, key := range accountToChannels {
|
||||||
|
tx.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrate cloak secret
|
||||||
|
val, _ := tx.Get("crypto.cloak_secret")
|
||||||
|
tx.Set(keyCloakSecret, val, nil)
|
||||||
|
|
||||||
|
// bump the legacy version key to mark the database as downgrade-incompatible
|
||||||
|
tx.Set("db.version", "23", nil)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webpush signing key
|
||||||
|
func schemaChangeV23ToV24(config *Config, tx *buntdb.Tx) error {
|
||||||
|
keys, err := webpush.GenerateVAPIDKeys()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
j, err := json.Marshal(keys)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx.Set(keyVAPIDKeys, string(j), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) {
|
func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) {
|
||||||
for _, change := range allChanges {
|
for _, change := range allChanges {
|
||||||
if initialVersion == change.InitialVersion {
|
if initialVersion == change.InitialVersion {
|
||||||
@ -1227,4 +1369,14 @@ var allChanges = []SchemaChange{
|
|||||||
TargetVersion: 22,
|
TargetVersion: 22,
|
||||||
Changer: schemaChangeV21To22,
|
Changer: schemaChangeV21To22,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
InitialVersion: 22,
|
||||||
|
TargetVersion: 23,
|
||||||
|
Changer: schemaChangeV22ToV23,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
InitialVersion: 23,
|
||||||
|
TargetVersion: 24,
|
||||||
|
Changer: schemaChangeV23ToV24,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
45
irc/datastore/datastore.go
Normal file
45
irc/datastore/datastore.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (c) 2022 Shivaram Lingamneni <slingamn@cs.stanford.edu>
|
||||||
|
// released under the MIT license
|
||||||
|
|
||||||
|
package datastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Table uint16
|
||||||
|
|
||||||
|
// XXX these are persisted and must remain stable;
|
||||||
|
// do not reorder, when deleting use _ to ensure that the deleted value is skipped
|
||||||
|
const (
|
||||||
|
TableMetadata Table = iota
|
||||||
|
TableChannels
|
||||||
|
TableChannelPurges
|
||||||
|
)
|
||||||
|
|
||||||
|
type KV struct {
|
||||||
|
UUID utils.UUID
|
||||||
|
Value []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Datastore provides the following abstraction:
|
||||||
|
// 1. Tables, each keyed on a UUID (the implementation is free to merge
|
||||||
|
// the table name and the UUID into a single key as long as the rest of
|
||||||
|
// the contract can be satisfied). Table names are [a-z0-9_]+
|
||||||
|
// 2. The ability to efficiently enumerate all uuid-value pairs in a table
|
||||||
|
// 3. Gets, sets, and deletes for individual (table, uuid) keys
|
||||||
|
type Datastore interface {
|
||||||
|
Backoff() time.Duration
|
||||||
|
|
||||||
|
GetAll(table Table) ([]KV, error)
|
||||||
|
|
||||||
|
// This is rarely used because it would typically lead to TOCTOU races
|
||||||
|
Get(table Table, key utils.UUID) (value []byte, err error)
|
||||||
|
|
||||||
|
Set(table Table, key utils.UUID, value []byte, expiration time.Time) error
|
||||||
|
|
||||||
|
// Note that deleting a nonexistent key is not considered an error
|
||||||
|
Delete(table Table, key utils.UUID) error
|
||||||
|
}
|
||||||
@ -4,9 +4,18 @@
|
|||||||
package email
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
dkim "github.com/toorop/go-dkim"
|
"fmt"
|
||||||
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
dkim "github.com/emersion/go-msgauth/dkim"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -17,38 +26,77 @@ type DKIMConfig struct {
|
|||||||
Domain string
|
Domain string
|
||||||
Selector string
|
Selector string
|
||||||
KeyFile string `yaml:"key-file"`
|
KeyFile string `yaml:"key-file"`
|
||||||
keyBytes []byte
|
privKey crypto.Signer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dkim *DKIMConfig) Enabled() bool {
|
||||||
|
return dkim.Domain != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dkim *DKIMConfig) Postprocess() (err error) {
|
func (dkim *DKIMConfig) Postprocess() (err error) {
|
||||||
if dkim.Domain != "" {
|
if !dkim.Enabled() {
|
||||||
if dkim.Selector == "" || dkim.KeyFile == "" {
|
return nil
|
||||||
return ErrMissingFields
|
|
||||||
}
|
|
||||||
dkim.keyBytes, err = os.ReadFile(dkim.KeyFile)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dkim.Selector == "" || dkim.KeyFile == "" {
|
||||||
|
return ErrMissingFields
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes, err := os.ReadFile(dkim.KeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Could not read DKIM key file: %w", err)
|
||||||
|
}
|
||||||
|
dkim.privKey, err = parseDKIMPrivKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Could not parse DKIM key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultOptions = dkim.SigOptions{
|
func parseDKIMPrivKey(input []byte) (crypto.Signer, error) {
|
||||||
Version: 1,
|
if len(input) == 0 {
|
||||||
Canonicalization: "relaxed/relaxed",
|
return nil, errors.New("DKIM private key is empty")
|
||||||
Algo: "rsa-sha256",
|
}
|
||||||
Headers: []string{"from", "to", "subject", "message-id", "date"},
|
|
||||||
BodyLength: 0,
|
// raw ed25519 private key format
|
||||||
QueryMethods: []string{"dns/txt"},
|
if len(input) == ed25519.PrivateKeySize {
|
||||||
AddSignatureTimestamp: true,
|
return ed25519.PrivateKey(input), nil
|
||||||
SignatureExpireIn: 0,
|
}
|
||||||
|
|
||||||
|
d, _ := pem.Decode(input)
|
||||||
|
if d == nil {
|
||||||
|
return nil, errors.New("Invalid PEM data for DKIM private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rsaKey, err := x509.ParsePKCS1PrivateKey(d.Bytes); err == nil {
|
||||||
|
return rsaKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if k, err := x509.ParsePKCS8PrivateKey(d.Bytes); err == nil {
|
||||||
|
switch key := k.(type) {
|
||||||
|
case *rsa.PrivateKey:
|
||||||
|
return key, nil
|
||||||
|
case ed25519.PrivateKey:
|
||||||
|
return key, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Unacceptable type for DKIM private key: %T", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("No acceptable format for DKIM private key")
|
||||||
}
|
}
|
||||||
|
|
||||||
func DKIMSign(message []byte, dkimConfig DKIMConfig) (result []byte, err error) {
|
func DKIMSign(message []byte, dkimConfig DKIMConfig) (result []byte, err error) {
|
||||||
options := defaultOptions
|
options := dkim.SignOptions{
|
||||||
options.PrivateKey = dkimConfig.keyBytes
|
Domain: dkimConfig.Domain,
|
||||||
options.Domain = dkimConfig.Domain
|
Selector: dkimConfig.Selector,
|
||||||
options.Selector = dkimConfig.Selector
|
Signer: dkimConfig.privKey,
|
||||||
err = dkim.Sign(&message, options)
|
HeaderCanonicalization: dkim.CanonicalizationRelaxed,
|
||||||
return message, err
|
BodyCanonicalization: dkim.CanonicalizationRelaxed,
|
||||||
|
}
|
||||||
|
input := bytes.NewBuffer(message)
|
||||||
|
output := bytes.NewBuffer(make([]byte, 0, len(message)+1024))
|
||||||
|
err = dkim.Sign(output, input, &options)
|
||||||
|
return output.Bytes(), err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,13 @@
|
|||||||
package email
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -23,35 +26,111 @@ var (
|
|||||||
ErrNoMXRecord = errors.New("Couldn't resolve MX record")
|
ErrNoMXRecord = errors.New("Couldn't resolve MX record")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type BlacklistSyntax uint
|
||||||
|
|
||||||
|
const (
|
||||||
|
BlacklistSyntaxGlob BlacklistSyntax = iota
|
||||||
|
BlacklistSyntaxRegexp
|
||||||
|
)
|
||||||
|
|
||||||
|
func blacklistSyntaxFromString(status string) (BlacklistSyntax, error) {
|
||||||
|
switch strings.ToLower(status) {
|
||||||
|
case "glob", "":
|
||||||
|
return BlacklistSyntaxGlob, nil
|
||||||
|
case "re", "regex", "regexp":
|
||||||
|
return BlacklistSyntaxRegexp, nil
|
||||||
|
default:
|
||||||
|
return BlacklistSyntaxRegexp, fmt.Errorf("Unknown blacklist syntax type `%s`", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bs *BlacklistSyntax) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||||
|
var orig string
|
||||||
|
var err error
|
||||||
|
if err = unmarshal(&orig); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result, err := blacklistSyntaxFromString(orig); err == nil {
|
||||||
|
*bs = result
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type MTAConfig struct {
|
type MTAConfig struct {
|
||||||
Server string
|
Server string
|
||||||
Port int
|
Port int
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
|
ImplicitTLS bool `yaml:"implicit-tls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MailtoConfig struct {
|
type MailtoConfig struct {
|
||||||
// legacy config format assumed the use of an MTA/smarthost,
|
// legacy config format assumed the use of an MTA/smarthost,
|
||||||
// so server, port, etc. appear directly at top level
|
// so server, port, etc. appear directly at top level
|
||||||
// XXX: see https://github.com/go-yaml/yaml/issues/63
|
// XXX: see https://github.com/go-yaml/yaml/issues/63
|
||||||
MTAConfig `yaml:",inline"`
|
MTAConfig `yaml:",inline"`
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Sender string
|
Sender string
|
||||||
HeloDomain string `yaml:"helo-domain"`
|
HeloDomain string `yaml:"helo-domain"`
|
||||||
RequireTLS bool `yaml:"require-tls"`
|
RequireTLS bool `yaml:"require-tls"`
|
||||||
VerifyMessageSubject string `yaml:"verify-message-subject"`
|
Protocol string `yaml:"protocol"`
|
||||||
DKIM DKIMConfig
|
LocalAddress string `yaml:"local-address"`
|
||||||
MTAReal MTAConfig `yaml:"mta"`
|
localAddress net.Addr
|
||||||
BlacklistRegexes []string `yaml:"blacklist-regexes"`
|
VerifyMessageSubject string `yaml:"verify-message-subject"`
|
||||||
blacklistRegexes []*regexp.Regexp
|
DKIM DKIMConfig
|
||||||
Timeout time.Duration
|
MTAReal MTAConfig `yaml:"mta"`
|
||||||
PasswordReset struct {
|
AddressBlacklist []string `yaml:"address-blacklist"`
|
||||||
|
AddressBlacklistSyntax BlacklistSyntax `yaml:"address-blacklist-syntax"`
|
||||||
|
AddressBlacklistFile string `yaml:"address-blacklist-file"`
|
||||||
|
blacklistRegexes []*regexp.Regexp
|
||||||
|
Timeout time.Duration
|
||||||
|
PasswordReset struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Cooldown custime.Duration
|
Cooldown custime.Duration
|
||||||
Timeout custime.Duration
|
Timeout custime.Duration
|
||||||
} `yaml:"password-reset"`
|
} `yaml:"password-reset"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (config *MailtoConfig) compileBlacklistEntry(source string) (re *regexp.Regexp, err error) {
|
||||||
|
if config.AddressBlacklistSyntax == BlacklistSyntaxGlob {
|
||||||
|
return utils.CompileGlob(source, false)
|
||||||
|
} else {
|
||||||
|
return regexp.Compile(fmt.Sprintf("^%s$", source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (config *MailtoConfig) processBlacklistFile(filename string) (result []*regexp.Regexp, err error) {
|
||||||
|
f, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
reader := bufio.NewReader(f)
|
||||||
|
lineNo := 0
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
lineNo++
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" && line[0] != '#' {
|
||||||
|
if compiled, compileErr := config.compileBlacklistEntry(line); compileErr == nil {
|
||||||
|
result = append(result, compiled)
|
||||||
|
} else {
|
||||||
|
return result, fmt.Errorf("Failed to compile line %d of blacklist-regex-file `%s`: %w", lineNo, line, compileErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch err {
|
||||||
|
case io.EOF:
|
||||||
|
return result, nil
|
||||||
|
case nil:
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
|
func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
|
||||||
if config.Sender == "" {
|
if config.Sender == "" {
|
||||||
return errors.New("Invalid mailto sender address")
|
return errors.New("Invalid mailto sender address")
|
||||||
@ -67,12 +146,39 @@ func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
|
|||||||
config.HeloDomain = heloDomain
|
config.HeloDomain = heloDomain
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, reg := range config.BlacklistRegexes {
|
if config.AddressBlacklistFile != "" {
|
||||||
compiled, err := regexp.Compile(fmt.Sprintf("^%s$", reg))
|
config.blacklistRegexes, err = config.processBlacklistFile(config.AddressBlacklistFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
config.blacklistRegexes = append(config.blacklistRegexes, compiled)
|
} else if len(config.AddressBlacklist) != 0 {
|
||||||
|
config.blacklistRegexes = make([]*regexp.Regexp, 0, len(config.AddressBlacklist))
|
||||||
|
for _, reg := range config.AddressBlacklist {
|
||||||
|
compiled, err := config.compileBlacklistEntry(reg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
config.blacklistRegexes = append(config.blacklistRegexes, compiled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Protocol = strings.ToLower(config.Protocol)
|
||||||
|
if config.Protocol == "" {
|
||||||
|
config.Protocol = "tcp"
|
||||||
|
}
|
||||||
|
if !(config.Protocol == "tcp" || config.Protocol == "tcp4" || config.Protocol == "tcp6") {
|
||||||
|
return fmt.Errorf("Invalid protocol for email sending: `%s`", config.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.LocalAddress != "" {
|
||||||
|
ipAddr := net.ParseIP(config.LocalAddress)
|
||||||
|
if ipAddr == nil {
|
||||||
|
return fmt.Errorf("Could not parse local-address for email sending: `%s`", config.LocalAddress)
|
||||||
|
}
|
||||||
|
config.localAddress = &net.TCPAddr{
|
||||||
|
IP: ipAddr,
|
||||||
|
Port: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.MTAConfig.Server != "" {
|
if config.MTAConfig.Server != "" {
|
||||||
@ -109,6 +215,9 @@ func ComposeMail(config MailtoConfig, recipient, subject string) (message bytes.
|
|||||||
dkimDomain := config.DKIM.Domain
|
dkimDomain := config.DKIM.Domain
|
||||||
if dkimDomain != "" {
|
if dkimDomain != "" {
|
||||||
fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), dkimDomain)
|
fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), dkimDomain)
|
||||||
|
} else {
|
||||||
|
// #2108: send Message-ID even if dkim is not enabled
|
||||||
|
fmt.Fprintf(&message, "Message-ID: <%s-%s>\r\n", utils.GenerateSecretKey(), config.Sender)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||||
fmt.Fprintf(&message, "Subject: %s\r\n", subject)
|
fmt.Fprintf(&message, "Subject: %s\r\n", subject)
|
||||||
@ -117,13 +226,14 @@ func ComposeMail(config MailtoConfig, recipient, subject string) (message bytes.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
|
func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
|
||||||
|
recipientLower := strings.ToLower(recipient)
|
||||||
for _, reg := range config.blacklistRegexes {
|
for _, reg := range config.blacklistRegexes {
|
||||||
if reg.MatchString(recipient) {
|
if reg.MatchString(recipientLower) {
|
||||||
return ErrBlacklistedAddress
|
return ErrBlacklistedAddress
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@ -132,11 +242,13 @@ func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
|
|||||||
|
|
||||||
var addr string
|
var addr string
|
||||||
var auth smtp.Auth
|
var auth smtp.Auth
|
||||||
|
var implicitTLS bool
|
||||||
if !config.DirectSendingEnabled() {
|
if !config.DirectSendingEnabled() {
|
||||||
addr = fmt.Sprintf("%s:%d", config.MTAReal.Server, config.MTAReal.Port)
|
addr = fmt.Sprintf("%s:%d", config.MTAReal.Server, config.MTAReal.Port)
|
||||||
if config.MTAReal.Username != "" && config.MTAReal.Password != "" {
|
if config.MTAReal.Username != "" && config.MTAReal.Password != "" {
|
||||||
auth = smtp.PlainAuth("", config.MTAReal.Username, config.MTAReal.Password, config.MTAReal.Server)
|
auth = smtp.PlainAuth("", config.MTAReal.Username, config.MTAReal.Password, config.MTAReal.Server)
|
||||||
}
|
}
|
||||||
|
implicitTLS = config.MTAReal.ImplicitTLS
|
||||||
} else {
|
} else {
|
||||||
idx := strings.IndexByte(recipient, '@')
|
idx := strings.IndexByte(recipient, '@')
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
@ -149,5 +261,8 @@ func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
|
|||||||
addr = fmt.Sprintf("%s:smtp", mx)
|
addr = fmt.Sprintf("%s:smtp", mx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return smtp.SendMail(addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg, config.RequireTLS, config.Timeout)
|
return smtp.SendMail(
|
||||||
|
addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg,
|
||||||
|
config.RequireTLS, implicitTLS, config.Protocol, config.localAddress, config.Timeout,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
@ -51,6 +52,7 @@ var (
|
|||||||
errNoExistingBan = errors.New("Ban does not exist")
|
errNoExistingBan = errors.New("Ban does not exist")
|
||||||
errNoSuchChannel = errors.New(`No such channel`)
|
errNoSuchChannel = errors.New(`No such channel`)
|
||||||
errChannelPurged = errors.New(`This channel was purged by the server operators and cannot be used`)
|
errChannelPurged = errors.New(`This channel was purged by the server operators and cannot be used`)
|
||||||
|
errChannelPurgedAlready = errors.New(`This channel was already purged and cannot be purged again`)
|
||||||
errConfusableIdentifier = errors.New("This identifier is confusable with one already in use")
|
errConfusableIdentifier = errors.New("This identifier is confusable with one already in use")
|
||||||
errInsufficientPrivs = errors.New("Insufficient privileges")
|
errInsufficientPrivs = errors.New("Insufficient privileges")
|
||||||
errInvalidUsername = errors.New("Invalid username")
|
errInvalidUsername = errors.New("Invalid username")
|
||||||
@ -75,13 +77,13 @@ 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
|
||||||
var (
|
var (
|
||||||
errCouldNotStabilize = errors.New("Could not stabilize string while casefolding")
|
errStringIsEmpty = errors.New("String is empty")
|
||||||
errStringIsEmpty = errors.New("String is empty")
|
errInvalidCharacter = errors.New("Invalid character")
|
||||||
errInvalidCharacter = errors.New("Invalid character")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type CertKeyError struct {
|
type CertKeyError struct {
|
||||||
@ -97,5 +99,5 @@ type ThrottleError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (te *ThrottleError) Error() string {
|
func (te *ThrottleError) Error() string {
|
||||||
return fmt.Sprintf(`Please wait at least %v and try again`, te.Duration)
|
return fmt.Sprintf(`Please wait at least %v and try again`, te.Duration.Round(time.Millisecond))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,8 @@
|
|||||||
package irc
|
package irc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"maps"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakelag is a system for artificially delaying commands when a user issues
|
// fakelag is a system for artificially delaying commands when a user issues
|
||||||
@ -40,7 +39,7 @@ func (fl *Fakelag) Initialize(config FakelagConfig) {
|
|||||||
fl.config = config
|
fl.config = config
|
||||||
// XXX don't share mutable member CommandBudgets:
|
// XXX don't share mutable member CommandBudgets:
|
||||||
if config.CommandBudgets != nil {
|
if config.CommandBudgets != nil {
|
||||||
fl.config.CommandBudgets = utils.CopyMap(config.CommandBudgets)
|
fl.config.CommandBudgets = maps.Clone(config.CommandBudgets)
|
||||||
}
|
}
|
||||||
fl.nowFunc = time.Now
|
fl.nowFunc = time.Now
|
||||||
fl.sleepFunc = time.Sleep
|
fl.sleepFunc = time.Sleep
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
504
irc/getters.go
504
irc/getters.go
@ -5,23 +5,23 @@ package irc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"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 {
|
||||||
@ -57,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
|
||||||
}
|
}
|
||||||
@ -77,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 {
|
||||||
@ -92,7 +94,7 @@ func (client *Client) AllSessionData(currentSession *Session, hasPrivs bool) (da
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) AddSession(session *Session) (success bool, numSessions int, lastSeen time.Time, back bool) {
|
func (client *Client) AddSession(session *Session) (success bool, numSessions int, lastSeen time.Time, wasAway, nowAway string) {
|
||||||
config := client.server.Config()
|
config := client.server.Config()
|
||||||
client.stateMutex.Lock()
|
client.stateMutex.Lock()
|
||||||
defer client.stateMutex.Unlock()
|
defer client.stateMutex.Unlock()
|
||||||
@ -110,17 +112,25 @@ 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
|
||||||
// TODO(#1551) there should be a cap to opt out of this behavior on a session
|
wasAway = client.awayMessage
|
||||||
if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) {
|
if client.autoAwayEnabledNoMutex(config) {
|
||||||
client.awayMessage = ""
|
client.setAutoAwayNoMutex(config)
|
||||||
if len(client.sessions) == 1 {
|
} else {
|
||||||
back = true
|
if session.awayMessage != "" && session.awayMessage != "*" {
|
||||||
|
// set the away message
|
||||||
|
client.awayMessage = session.awayMessage
|
||||||
|
} else if session.awayMessage == "" && !session.awayAt.IsZero() {
|
||||||
|
// weird edge case: explicit `AWAY` or `AWAY :` during pre-registration makes the client back
|
||||||
|
client.awayMessage = ""
|
||||||
}
|
}
|
||||||
|
// else: the client sent no AWAY command at all, no-op
|
||||||
|
// or: the client sent `AWAY *`, which should not modify the publicly visible away state
|
||||||
}
|
}
|
||||||
return true, len(client.sessions), lastSeen, back
|
nowAway = client.awayMessage
|
||||||
|
return true, len(client.sessions), lastSeen, wasAway, nowAway
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) removeSession(session *Session) (success bool, length int) {
|
func (client *Client) removeSession(session *Session) (success bool, length int) {
|
||||||
@ -195,7 +205,7 @@ func (client *Client) Away() (result bool, message string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session *Session) SetAway(awayMessage string) {
|
func (session *Session) SetAway(awayMessage string) (wasAway, nowAway string) {
|
||||||
client := session.client
|
client := session.client
|
||||||
config := client.server.Config()
|
config := client.server.Config()
|
||||||
|
|
||||||
@ -205,15 +215,28 @@ func (session *Session) SetAway(awayMessage string) {
|
|||||||
session.awayMessage = awayMessage
|
session.awayMessage = awayMessage
|
||||||
session.awayAt = time.Now().UTC()
|
session.awayAt = time.Now().UTC()
|
||||||
|
|
||||||
autoAway := client.registered && client.alwaysOn && persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway)
|
wasAway = client.awayMessage
|
||||||
if autoAway {
|
if client.autoAwayEnabledNoMutex(config) {
|
||||||
client.setAutoAwayNoMutex(config)
|
client.setAutoAwayNoMutex(config)
|
||||||
} else {
|
} else if awayMessage != "*" {
|
||||||
client.awayMessage = awayMessage
|
client.awayMessage = awayMessage
|
||||||
}
|
} // else: `AWAY *`, should not modify publicly visible away state
|
||||||
|
nowAway = client.awayMessage
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (session *Session) ConnID() string {
|
||||||
|
if session == nil {
|
||||||
|
return "*"
|
||||||
|
}
|
||||||
|
return session.connID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) autoAwayEnabledNoMutex(config *Config) bool {
|
||||||
|
return client.registered && client.alwaysOn &&
|
||||||
|
persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway)
|
||||||
|
}
|
||||||
|
|
||||||
func (client *Client) setAutoAwayNoMutex(config *Config) {
|
func (client *Client) setAutoAwayNoMutex(config *Config) {
|
||||||
// aggregate the away statuses of the individual sessions:
|
// aggregate the away statuses of the individual sessions:
|
||||||
var globalAwayState string
|
var globalAwayState string
|
||||||
@ -223,8 +246,8 @@ func (client *Client) setAutoAwayNoMutex(config *Config) {
|
|||||||
// a session is active, we are not auto-away
|
// a session is active, we are not auto-away
|
||||||
client.awayMessage = ""
|
client.awayMessage = ""
|
||||||
return
|
return
|
||||||
} else if cSession.awayAt.After(awaySetAt) {
|
} else if cSession.awayAt.After(awaySetAt) && cSession.awayMessage != "*" {
|
||||||
// choose the latest available away message from any session
|
// choose the latest valid away message from any session
|
||||||
globalAwayState = cSession.awayMessage
|
globalAwayState = cSession.awayMessage
|
||||||
awaySetAt = cSession.awayAt
|
awaySetAt = cSession.awayAt
|
||||||
}
|
}
|
||||||
@ -475,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
|
||||||
@ -493,15 +519,22 @@ 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()
|
||||||
return utils.CopyMap(client.readMarkers)
|
return maps.Clone(client.readMarkers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) SetReadMarker(cfname string, now time.Time) (result time.Time) {
|
func (client *Client) SetReadMarker(cfname string, now time.Time) (result time.Time) {
|
||||||
@ -536,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()
|
||||||
@ -551,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()
|
||||||
@ -601,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) {
|
||||||
@ -614,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) {
|
||||||
@ -638,3 +825,268 @@ func (channel *Channel) getAmode(cfaccount string) (result modes.Mode) {
|
|||||||
defer channel.stateMutex.RUnlock()
|
defer channel.stateMutex.RUnlock()
|
||||||
return channel.accountToUMode[cfaccount]
|
return channel.accountToUMode[cfaccount]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (channel *Channel) UUID() utils.UUID {
|
||||||
|
channel.stateMutex.RLock()
|
||||||
|
defer channel.stateMutex.RUnlock()
|
||||||
|
return channel.uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) isSubscribedTo(key string) bool {
|
||||||
|
session.client.stateMutex.RLock()
|
||||||
|
defer session.client.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
return session.metadataSubscriptions.Has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) SubscribeTo(keys ...string) ([]string, error) {
|
||||||
|
maxSubs := session.client.server.Config().Metadata.MaxSubs
|
||||||
|
|
||||||
|
session.client.stateMutex.Lock()
|
||||||
|
defer session.client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
if session.metadataSubscriptions == nil {
|
||||||
|
session.metadataSubscriptions = make(utils.HashSet[string])
|
||||||
|
}
|
||||||
|
|
||||||
|
var added []string
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
if !session.metadataSubscriptions.Has(k) {
|
||||||
|
if len(session.metadataSubscriptions) > maxSubs {
|
||||||
|
return added, errMetadataTooManySubs
|
||||||
|
}
|
||||||
|
added = append(added, k)
|
||||||
|
session.metadataSubscriptions.Add(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return added, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) UnsubscribeFrom(keys ...string) []string {
|
||||||
|
session.client.stateMutex.Lock()
|
||||||
|
defer session.client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
var removed []string
|
||||||
|
|
||||||
|
for k := range session.metadataSubscriptions {
|
||||||
|
if slices.Contains(keys, k) {
|
||||||
|
removed = append(removed, k)
|
||||||
|
session.metadataSubscriptions.Remove(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *Session) MetadataSubscriptions() utils.HashSet[string] {
|
||||||
|
session.client.stateMutex.Lock()
|
||||||
|
defer session.client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
return maps.Clone(session.metadataSubscriptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channel *Channel) GetMetadata(key string) (string, bool) {
|
||||||
|
channel.stateMutex.RLock()
|
||||||
|
defer channel.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
val, ok := channel.metadata[key]
|
||||||
|
return val, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channel *Channel) SetMetadata(key string, value string, limit int) (updated bool, err error) {
|
||||||
|
defer channel.MarkDirty(IncludeAllAttrs)
|
||||||
|
|
||||||
|
channel.stateMutex.Lock()
|
||||||
|
defer channel.stateMutex.Unlock()
|
||||||
|
|
||||||
|
if channel.metadata == nil {
|
||||||
|
channel.metadata = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, ok := channel.metadata[key]
|
||||||
|
if !ok && len(channel.metadata) >= limit {
|
||||||
|
return false, errLimitExceeded
|
||||||
|
}
|
||||||
|
updated = !ok || value != existing
|
||||||
|
if updated {
|
||||||
|
channel.metadata[key] = value
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channel *Channel) ListMetadata() map[string]string {
|
||||||
|
channel.stateMutex.RLock()
|
||||||
|
defer channel.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
return maps.Clone(channel.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channel *Channel) DeleteMetadata(key string) (updated bool) {
|
||||||
|
defer channel.MarkDirty(IncludeAllAttrs)
|
||||||
|
|
||||||
|
channel.stateMutex.Lock()
|
||||||
|
defer channel.stateMutex.Unlock()
|
||||||
|
|
||||||
|
_, updated = channel.metadata[key]
|
||||||
|
if updated {
|
||||||
|
delete(channel.metadata, key)
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channel *Channel) ClearMetadata() map[string]string {
|
||||||
|
defer channel.MarkDirty(IncludeAllAttrs)
|
||||||
|
channel.stateMutex.Lock()
|
||||||
|
defer channel.stateMutex.Unlock()
|
||||||
|
|
||||||
|
oldMap := channel.metadata
|
||||||
|
channel.metadata = nil
|
||||||
|
|
||||||
|
return oldMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channel *Channel) CountMetadata() int {
|
||||||
|
channel.stateMutex.RLock()
|
||||||
|
defer channel.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
return len(channel.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GetMetadata(key string) (string, bool) {
|
||||||
|
client.stateMutex.RLock()
|
||||||
|
defer client.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
val, ok := client.metadata[key]
|
||||||
|
return val, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) SetMetadata(key string, value string, limit int) (updated bool, err error) {
|
||||||
|
var alwaysOn bool
|
||||||
|
defer func() {
|
||||||
|
if alwaysOn && updated {
|
||||||
|
client.markDirty(IncludeMetadata)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client.stateMutex.Lock()
|
||||||
|
defer client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
alwaysOn = client.registered && client.alwaysOn
|
||||||
|
|
||||||
|
if client.metadata == nil {
|
||||||
|
client.metadata = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, ok := client.metadata[key]
|
||||||
|
if !ok && len(client.metadata) >= limit {
|
||||||
|
return false, errLimitExceeded
|
||||||
|
}
|
||||||
|
updated = !ok || value != existing
|
||||||
|
if updated {
|
||||||
|
client.metadata[key] = value
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) UpdateMetadataFromPrereg(preregData map[string]string, limit int) (updates map[string]string) {
|
||||||
|
var alwaysOn bool
|
||||||
|
defer func() {
|
||||||
|
if alwaysOn && len(updates) > 0 {
|
||||||
|
client.markDirty(IncludeMetadata)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
updates = make(map[string]string, len(preregData))
|
||||||
|
|
||||||
|
client.stateMutex.Lock()
|
||||||
|
defer client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
alwaysOn = client.registered && client.alwaysOn
|
||||||
|
|
||||||
|
if client.metadata == nil {
|
||||||
|
client.metadata = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range preregData {
|
||||||
|
// do not overwrite any existing keys
|
||||||
|
_, ok := client.metadata[k]
|
||||||
|
if ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(client.metadata) >= limit {
|
||||||
|
return // we know this is a new key
|
||||||
|
}
|
||||||
|
client.metadata[k] = v
|
||||||
|
updates[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) ListMetadata() map[string]string {
|
||||||
|
client.stateMutex.RLock()
|
||||||
|
defer client.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
return maps.Clone(client.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) DeleteMetadata(key string) (updated bool) {
|
||||||
|
defer func() {
|
||||||
|
if updated {
|
||||||
|
client.markDirty(IncludeMetadata)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client.stateMutex.Lock()
|
||||||
|
defer client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
_, updated = client.metadata[key]
|
||||||
|
if updated {
|
||||||
|
delete(client.metadata, key)
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) ClearMetadata() (oldMap map[string]string) {
|
||||||
|
defer func() {
|
||||||
|
if len(oldMap) > 0 {
|
||||||
|
client.markDirty(IncludeMetadata)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client.stateMutex.Lock()
|
||||||
|
defer client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
oldMap = client.metadata
|
||||||
|
client.metadata = nil
|
||||||
|
|
||||||
|
return oldMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) CountMetadata() int {
|
||||||
|
client.stateMutex.RLock()
|
||||||
|
defer client.stateMutex.RUnlock()
|
||||||
|
|
||||||
|
return len(client.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) checkMetadataThrottle() (throttled bool, remainingTime time.Duration) {
|
||||||
|
config := client.server.Config()
|
||||||
|
if !config.Metadata.ClientThrottle.Enabled {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
client.stateMutex.Lock()
|
||||||
|
defer client.stateMutex.Unlock()
|
||||||
|
|
||||||
|
// copy client.metadataThrottle locally and then back for processing
|
||||||
|
var throttle connection_limits.GenericThrottle
|
||||||
|
throttle.ThrottleDetails = client.metadataThrottle
|
||||||
|
throttle.Duration = config.Metadata.ClientThrottle.Duration
|
||||||
|
throttle.Limit = config.Metadata.ClientThrottle.MaxAttempts
|
||||||
|
throttled, remainingTime = throttle.Touch()
|
||||||
|
client.metadataThrottle = throttle.ThrottleDetails
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|||||||
930
irc/handlers.go
930
irc/handlers.go
File diff suppressed because it is too large
Load Diff
31
irc/help.go
31
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>...]]
|
||||||
@ -435,6 +445,12 @@ Replies to a PING. Used to check link connectivity.`,
|
|||||||
text: `PRIVMSG <target>{,<target>} <text to be sent>
|
text: `PRIVMSG <target>{,<target>} <text to be sent>
|
||||||
|
|
||||||
Sends the text to the given targets as a PRIVMSG.`,
|
Sends the text to the given targets as a PRIVMSG.`,
|
||||||
|
},
|
||||||
|
"redact": {
|
||||||
|
text: `REDACT <target> <targetmsgid> [<reason>]
|
||||||
|
|
||||||
|
Removes the message of the target msgid from the chat history of a channel
|
||||||
|
or target user.`,
|
||||||
},
|
},
|
||||||
"relaymsg": {
|
"relaymsg": {
|
||||||
text: `RELAYMSG <channel> <spoofed nick> :<message>
|
text: `RELAYMSG <channel> <spoofed nick> :<message>
|
||||||
@ -599,6 +615,11 @@ ircv3.net/specs/extensions/webirc.html
|
|||||||
the connection from the client to the gateway, such as:
|
the connection from the client to the gateway, such as:
|
||||||
|
|
||||||
- tls: this flag indicates that the client->gateway connection is secure`,
|
- tls: this flag indicates that the client->gateway connection is secure`,
|
||||||
|
},
|
||||||
|
"webpush": {
|
||||||
|
text: `WEBPUSH <subcommand> [arguments]
|
||||||
|
|
||||||
|
Configures web push settings. Not for direct use by end users.`,
|
||||||
},
|
},
|
||||||
"who": {
|
"who": {
|
||||||
text: `WHO <name> [o]
|
text: `WHO <name> [o]
|
||||||
|
|||||||
126
irc/history/database.go
Normal file
126
irc/history/database.go
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
// Copyright (c) 2025 Shivaram Lingamneni
|
||||||
|
// released under the MIT license
|
||||||
|
|
||||||
|
package history
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDisallowed = errors.New("disallowed")
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Database is an interface for persistent history storage backends.
|
||||||
|
type Database interface {
|
||||||
|
// Close closes the database connection and releases resources.
|
||||||
|
io.Closer
|
||||||
|
|
||||||
|
// AddChannelItem adds a history item for a channel.
|
||||||
|
// target is the casefolded channel name.
|
||||||
|
// account is the sender's casefolded account name ("" for no account).
|
||||||
|
AddChannelItem(target string, item Item, account string) error
|
||||||
|
|
||||||
|
// AddDirectMessage adds a history item for a direct message.
|
||||||
|
// All identifiers are casefolded; account identifiers are "" for no account.
|
||||||
|
AddDirectMessage(sender, senderAccount, recipient, recipientAccount string, item Item) error
|
||||||
|
|
||||||
|
// DeleteMsgid deletes a message by its msgid.
|
||||||
|
// accountName is the unfolded account name, or "*" to skip
|
||||||
|
// account validation
|
||||||
|
DeleteMsgid(msgid, accountName string) error
|
||||||
|
|
||||||
|
// MakeSequence creates a Sequence for querying history.
|
||||||
|
// target is the primary target (channel or account), casefolded.
|
||||||
|
// correspondent is the casefolded DM correspondent (empty for channels).
|
||||||
|
// cutoff is the earliest time to include in results.
|
||||||
|
MakeSequence(target, correspondent string, cutoff time.Time) Sequence
|
||||||
|
|
||||||
|
// ListChannels returns the timestamp of the latest message in each
|
||||||
|
// of the given channels (specified as casefolded names).
|
||||||
|
ListChannels(cfchannels []string) (results []TargetListing, err error)
|
||||||
|
|
||||||
|
// ListCorrespondents lists the DM correspondents associated with an account,
|
||||||
|
// in order to implement CHATHISTORY TARGETS.
|
||||||
|
ListCorrespondents(cftarget string, start, end time.Time, limit int) ([]TargetListing, error)
|
||||||
|
|
||||||
|
// these are for theoretical GDPR compliance, not actual chat functionality,
|
||||||
|
// and are not essential:
|
||||||
|
|
||||||
|
// Forget enqueues an account (casefolded) for message deletion.
|
||||||
|
// This is used for GDPR-style "right to be forgotten" requests.
|
||||||
|
// The actual deletion happens asynchronously.
|
||||||
|
Forget(account string)
|
||||||
|
|
||||||
|
// Export exports all messages for an account (casefolded) to the given writer.
|
||||||
|
Export(account string, writer io.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopDatabase struct{}
|
||||||
|
|
||||||
|
// NewNoopDatabase returns a Database implementation that does nothing.
|
||||||
|
func NewNoopDatabase() Database {
|
||||||
|
return noopDatabase{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDatabase) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDatabase) AddChannelItem(target string, item Item, account string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDatabase) AddDirectMessage(sender, senderAccount, recipient, recipientAccount string, item Item) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDatabase) DeleteMsgid(msgid, accountName string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDatabase) Forget(account string) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDatabase) Export(account string, writer io.Writer) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDatabase) ListChannels(cfchannels []string) (results []TargetListing, err error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDatabase) ListCorrespondents(target string, start, end time.Time, limit int) (results []TargetListing, err error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopDatabase) MakeSequence(target, correspondent string, cutoff time.Time) Sequence {
|
||||||
|
return noopSequence{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noopSequence is a no-op implementation of Sequence.
|
||||||
|
// XXX: this should never be accessed, because if persistent history is disabled,
|
||||||
|
// we should always be working with a bufferSequence instead. But we might as well
|
||||||
|
// be defensive in case there's an edge case where (noopDatabase).MakeSequence ends
|
||||||
|
// up getting called.
|
||||||
|
type noopSequence struct{}
|
||||||
|
|
||||||
|
func (n noopSequence) Between(start, end Selector, limit int) (results []Item, err error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopSequence) Around(start Selector, limit int) (results []Item, err error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopSequence) Cutoff() time.Time {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n noopSequence) Ephemeral() bool {
|
||||||
|
return false // we're pretending to be an empty database
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
package history
|
package history
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -155,7 +156,7 @@ func (list *Buffer) betweenHelper(start, end Selector, cutoff time.Time, pred Pr
|
|||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if !ascending {
|
if !ascending {
|
||||||
utils.ReverseSlice(results)
|
slices.Reverse(results)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -229,10 +230,8 @@ func (list *Buffer) allCorrespondents() (results []TargetListing) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// list DM correspondents, as one input to CHATHISTORY TARGETS
|
// list DM correspondents, as one input to CHATHISTORY TARGETS
|
||||||
func (list *Buffer) listCorrespondents(start, end Selector, cutoff time.Time, limit int) (results []TargetListing, err error) {
|
func (list *Buffer) ListCorrespondents(start, end time.Time, limit int) (results []TargetListing, err error) {
|
||||||
after := start.Time
|
after, before, ascending := MinMaxAsc(start, end, time.Time{})
|
||||||
before := end.Time
|
|
||||||
after, before, ascending := MinMaxAsc(after, before, cutoff)
|
|
||||||
|
|
||||||
correspondents := list.allCorrespondents()
|
correspondents := list.allCorrespondents()
|
||||||
if len(correspondents) == 0 {
|
if len(correspondents) == 0 {
|
||||||
@ -262,7 +261,7 @@ func (list *Buffer) listCorrespondents(start, end Selector, cutoff time.Time, li
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !ascending {
|
if !ascending {
|
||||||
utils.ReverseSlice(results)
|
slices.Reverse(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -299,10 +298,6 @@ func (seq *bufferSequence) Around(start Selector, limit int) (results []Item, er
|
|||||||
return GenericAround(seq, start, limit)
|
return GenericAround(seq, start, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (seq *bufferSequence) ListCorrespondents(start, end Selector, limit int) (results []TargetListing, err error) {
|
|
||||||
return seq.list.listCorrespondents(start, end, seq.cutoff, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (seq *bufferSequence) Cutoff() time.Time {
|
func (seq *bufferSequence) Cutoff() time.Time {
|
||||||
return seq.cutoff
|
return seq.cutoff
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,8 +21,6 @@ type Sequence interface {
|
|||||||
Between(start, end Selector, limit int) (results []Item, err error)
|
Between(start, end Selector, limit int) (results []Item, err error)
|
||||||
Around(start Selector, limit int) (results []Item, err error)
|
Around(start Selector, limit int) (results []Item, err error)
|
||||||
|
|
||||||
ListCorrespondents(start, end Selector, limit int) (results []TargetListing, err error)
|
|
||||||
|
|
||||||
// this are weird hacks that violate the encapsulation of Sequence to some extent;
|
// this are weird hacks that violate the encapsulation of Sequence to some extent;
|
||||||
// Cutoff() returns the cutoff time for other code to use (it returns the zero time
|
// Cutoff() returns the cutoff time for other code to use (it returns the zero time
|
||||||
// if none is set), and Ephemeral() returns whether the backing store is in-memory
|
// if none is set), and Ephemeral() returns whether the backing store is in-memory
|
||||||
|
|||||||
19
irc/history/serialization.go
Normal file
19
irc/history/serialization.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// Copyright (c) 2020 Shivaram Lingamneni
|
||||||
|
// released under the MIT license
|
||||||
|
|
||||||
|
package history
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 123 / '{' is the magic number that means JSON;
|
||||||
|
// if we want to do a binary encoding later, we just have to add different magic version numbers
|
||||||
|
|
||||||
|
func MarshalItem(item *Item) (result []byte, err error) {
|
||||||
|
return json.Marshal(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnmarshalItem(data []byte, result *Item) (err error) {
|
||||||
|
return json.Unmarshal(data, result)
|
||||||
|
}
|
||||||
@ -4,10 +4,9 @@
|
|||||||
package history
|
package history
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TargetListing struct {
|
type TargetListing struct {
|
||||||
@ -35,8 +34,8 @@ func MergeTargets(base []TargetListing, extra []TargetListing, start, end time.T
|
|||||||
results = make([]TargetListing, 0, prealloc)
|
results = make([]TargetListing, 0, prealloc)
|
||||||
|
|
||||||
if !ascending {
|
if !ascending {
|
||||||
utils.ReverseSlice(base)
|
slices.Reverse(base)
|
||||||
utils.ReverseSlice(extra)
|
slices.Reverse(extra)
|
||||||
}
|
}
|
||||||
|
|
||||||
for len(results) < limit {
|
for len(results) < limit {
|
||||||
@ -66,7 +65,7 @@ func MergeTargets(base []TargetListing, extra []TargetListing, start, end time.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !ascending {
|
if !ascending {
|
||||||
utils.ReverseSlice(results)
|
slices.Reverse(results)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,14 @@ import (
|
|||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type CanDelete uint
|
||||||
|
|
||||||
|
const (
|
||||||
|
canDeleteAny CanDelete = iota // User is allowed to delete any message (for a given channel/PM)
|
||||||
|
canDeleteSelf // User is allowed to delete their own messages (ditto)
|
||||||
|
canDeleteNone // User is not allowed to delete any message (ditto)
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
histservHelp = `HistServ provides commands related to history.`
|
histservHelp = `HistServ provides commands related to history.`
|
||||||
)
|
)
|
||||||
@ -92,33 +100,53 @@ func histservForgetHandler(service *ircService, server *Server, client *Client,
|
|||||||
service.Notice(rb, fmt.Sprintf(client.t("Enqueued account %s for message deletion"), accountName))
|
service.Notice(rb, fmt.Sprintf(client.t("Enqueued account %s for message deletion"), accountName))
|
||||||
}
|
}
|
||||||
|
|
||||||
func histservDeleteHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
// Returns:
|
||||||
target, msgid := params[0], params[1] // Fix #1881 2 params are required
|
//
|
||||||
|
// 1. `canDeleteAny` if the client allowed to delete other users' messages from the target, ie.:
|
||||||
// operators can delete; if individual delete is allowed, a chanop or
|
// - the client is a channel operator, or
|
||||||
// the message author can delete
|
// - the client is an operator with "history" capability
|
||||||
accountName := "*"
|
//
|
||||||
isChanop := false
|
// 2. `canDeleteSelf` if the client is allowed to delete their own messages from the target
|
||||||
|
// 3. `canDeleteNone` otherwise
|
||||||
|
func deletionPolicy(server *Server, client *Client, target string) CanDelete {
|
||||||
isOper := client.HasRoleCapabs("history")
|
isOper := client.HasRoleCapabs("history")
|
||||||
if !isOper {
|
if isOper {
|
||||||
|
return canDeleteAny
|
||||||
|
} else {
|
||||||
if server.Config().History.Retention.AllowIndividualDelete {
|
if server.Config().History.Retention.AllowIndividualDelete {
|
||||||
channel := server.channels.Get(target)
|
channel := server.channels.Get(target)
|
||||||
if channel != nil && channel.ClientIsAtLeast(client, modes.Operator) {
|
if channel != nil && channel.ClientIsAtLeast(client, modes.Operator) {
|
||||||
isChanop = true
|
return canDeleteAny
|
||||||
} else {
|
} else {
|
||||||
accountName = client.AccountName()
|
return canDeleteSelf
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return canDeleteNone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !isOper && !isChanop && accountName == "*" {
|
}
|
||||||
|
|
||||||
|
func histservDeleteHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
|
||||||
|
target, msgid := params[0], params[1] // Fix #1881 2 params are required
|
||||||
|
|
||||||
|
canDelete := deletionPolicy(server, client, target)
|
||||||
|
accountName := "*"
|
||||||
|
if canDelete == canDeleteNone {
|
||||||
service.Notice(rb, client.t("Insufficient privileges"))
|
service.Notice(rb, client.t("Insufficient privileges"))
|
||||||
return
|
return
|
||||||
|
} else if canDelete == canDeleteSelf {
|
||||||
|
accountName = client.AccountName()
|
||||||
|
if accountName == "*" {
|
||||||
|
service.Notice(rb, client.t("Insufficient privileges"))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := server.DeleteMessage(target, msgid, accountName)
|
err := server.DeleteMessage(target, msgid, accountName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
service.Notice(rb, client.t("Successfully deleted message"))
|
service.Notice(rb, client.t("Successfully deleted message"))
|
||||||
} else {
|
} else {
|
||||||
|
isOper := client.HasRoleCapabs("history")
|
||||||
if isOper {
|
if isOper {
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Error deleting message: %v"), err))
|
service.Notice(rb, fmt.Sprintf(client.t("Error deleting message: %v"), err))
|
||||||
} else {
|
} else {
|
||||||
@ -136,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 {
|
||||||
@ -149,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)
|
||||||
|
|||||||
@ -193,6 +193,6 @@ func hsSetCloakSecretHandler(service *ircService, server *Server, client *Client
|
|||||||
service.Notice(rb, fmt.Sprintf(client.t("To confirm, run this command: %s"), fmt.Sprintf("/HS SETCLOAKSECRET %s %s", secret, expectedCode)))
|
service.Notice(rb, fmt.Sprintf(client.t("To confirm, run this command: %s"), fmt.Sprintf("/HS SETCLOAKSECRET %s %s", secret, expectedCode)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
StoreCloakSecret(server.store, secret)
|
StoreCloakSecret(server.dstore, secret)
|
||||||
service.Notice(rb, client.t("Rotated the cloak secret; you must rehash or restart the server for it to take effect"))
|
service.Notice(rb, client.t("Rotated the cloak secret; you must rehash or restart the server for it to take effect"))
|
||||||
}
|
}
|
||||||
|
|||||||
79
irc/i18n/common.go
Normal file
79
irc/i18n/common.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Casemapping represents a set of algorithm for case normalization
|
||||||
|
// and confusables prevention for IRC identifiers (nicknames and channel names)
|
||||||
|
type Casemapping uint
|
||||||
|
|
||||||
|
const (
|
||||||
|
// "precis" is the default / zero value:
|
||||||
|
// casefolding/validation: PRECIS + ircd restrictions (like no *)
|
||||||
|
// confusables detection: standard skeleton algorithm
|
||||||
|
CasemappingPRECIS Casemapping = iota
|
||||||
|
// "ascii" is the traditional ircd behavior:
|
||||||
|
// casefolding/validation: must be pure ASCII and follow ircd restrictions, ASCII lowercasing
|
||||||
|
// confusables detection: none
|
||||||
|
CasemappingASCII
|
||||||
|
// "permissive" is an insecure mode:
|
||||||
|
// casefolding/validation: arbitrary unicodes that follow ircd restrictions, unicode casefolding
|
||||||
|
// confusables detection: standard skeleton algorithm (which may be ineffective
|
||||||
|
// over the larger set of permitted identifiers)
|
||||||
|
CasemappingPermissive
|
||||||
|
// rfc1459 is a legacy mapping as defined here: https://modern.ircdocs.horse/#casemapping-parameter
|
||||||
|
CasemappingRFC1459
|
||||||
|
// rfc1459-strict is a legacy mapping as defined here: https://modern.ircdocs.horse/#casemapping-parameter
|
||||||
|
CasemappingRFC1459Strict
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidCharacter = errors.New("Invalid character")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (cm *Casemapping) UnmarshalYAML(unmarshal func(interface{}) error) (err error) {
|
||||||
|
var orig string
|
||||||
|
if err = unmarshal(&orig); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result Casemapping
|
||||||
|
switch strings.ToLower(orig) {
|
||||||
|
case "ascii":
|
||||||
|
result = CasemappingASCII
|
||||||
|
case "precis", "rfc7613", "rfc8265":
|
||||||
|
result = CasemappingPRECIS
|
||||||
|
case "permissive", "fun":
|
||||||
|
result = CasemappingPermissive
|
||||||
|
case "rfc1459":
|
||||||
|
result = CasemappingRFC1459
|
||||||
|
case "rfc1459-strict":
|
||||||
|
result = CasemappingRFC1459Strict
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid casemapping value: %s", orig)
|
||||||
|
}
|
||||||
|
*cm = result
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrintableASCII(str string) bool {
|
||||||
|
for i := 0; i < len(str); i++ {
|
||||||
|
// allow space here because it's technically printable;
|
||||||
|
// it will be disallowed later by CasefoldName/CasefoldChannel
|
||||||
|
chr := str[i]
|
||||||
|
if chr < ' ' || chr > '~' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func foldASCII(str string) (result string, err error) {
|
||||||
|
if !isPrintableASCII(str) {
|
||||||
|
return "", errInvalidCharacter
|
||||||
|
}
|
||||||
|
return strings.ToLower(str), nil
|
||||||
|
}
|
||||||
132
irc/i18n/strings.go
Normal file
132
irc/i18n/strings.go
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
//go:build i18n
|
||||||
|
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ergochat/confusables"
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/secure/precis"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
|
"golang.org/x/text/width"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Enabled = true
|
||||||
|
|
||||||
|
// 1.x configurations don't have a server.casemapping field, but
|
||||||
|
// expect PRECIS. however, technically it's not this value that
|
||||||
|
// causes them to get PRECIS, it's that PRECIS is the zero value of
|
||||||
|
// Casemapping (so that's how the YAML deserializes when the field
|
||||||
|
// is missing).
|
||||||
|
DefaultCasemapping = CasemappingPRECIS
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// reviving the old ergonomadic nickname regex:
|
||||||
|
// in permissive mode, allow arbitrary letters, numbers, punctuation, and symbols
|
||||||
|
permissiveCharsRegex = regexp.MustCompile(`^[\pL\pN\pP\pS]*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// String Errors
|
||||||
|
var (
|
||||||
|
errCouldNotStabilize = errors.New("Could not stabilize string while casefolding")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each pass of PRECIS casefolding is a composition of idempotent operations,
|
||||||
|
// but not idempotent itself. Therefore, the spec says "do it four times and hope
|
||||||
|
// it converges" (lolwtf). Golang's PRECIS implementation has a "repeat" option,
|
||||||
|
// which provides this functionality, but unfortunately it's not exposed publicly.
|
||||||
|
func iterateFolding(profile *precis.Profile, oldStr string) (str string, err error) {
|
||||||
|
str = oldStr
|
||||||
|
// follow the stabilizing rules laid out here:
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-precis-7564bis-10.html#section-7
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
str, err = profile.CompareKey(str)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if oldStr == str {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
oldStr = str
|
||||||
|
}
|
||||||
|
if oldStr != str {
|
||||||
|
return "", errCouldNotStabilize
|
||||||
|
}
|
||||||
|
return str, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func foldPRECIS(str string) (result string, err error) {
|
||||||
|
return iterateFolding(precis.UsernameCaseMapped, str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func foldPermissive(str string) (result string, err error) {
|
||||||
|
if !permissiveCharsRegex.MatchString(str) {
|
||||||
|
return "", errInvalidCharacter
|
||||||
|
}
|
||||||
|
// YOLO
|
||||||
|
str = norm.NFD.String(str)
|
||||||
|
str = cases.Fold().String(str)
|
||||||
|
str = norm.NFD.String(str)
|
||||||
|
return str, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
rfc1459Replacer = strings.NewReplacer("[", "{", "]", "}", "\\", "|", "~", "^")
|
||||||
|
rfc1459StrictReplacer = strings.NewReplacer("[", "{", "]", "}", "\\", "|")
|
||||||
|
)
|
||||||
|
|
||||||
|
func foldRFC1459(str string, strict bool) (result string, err error) {
|
||||||
|
asciiFold, err := foldASCII(str)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
replacer := rfc1459Replacer
|
||||||
|
if strict {
|
||||||
|
replacer = rfc1459StrictReplacer
|
||||||
|
}
|
||||||
|
return replacer.Replace(asciiFold), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CasefoldWithSetting(str string, setting Casemapping) (string, error) {
|
||||||
|
switch setting {
|
||||||
|
default:
|
||||||
|
return foldPRECIS(str)
|
||||||
|
case CasemappingASCII:
|
||||||
|
return foldASCII(str)
|
||||||
|
case CasemappingPermissive:
|
||||||
|
return foldPermissive(str)
|
||||||
|
case CasemappingRFC1459:
|
||||||
|
return foldRFC1459(str, false)
|
||||||
|
case CasemappingRFC1459Strict:
|
||||||
|
return foldRFC1459(str, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skeleton produces a canonicalized identifier that tries to catch
|
||||||
|
// homoglyphic / confusable identifiers. It's a tweaked version of the TR39
|
||||||
|
// skeleton algorithm. We apply the skeleton algorithm first and only then casefold,
|
||||||
|
// because casefolding first would lose some information about visual confusability.
|
||||||
|
// This has the weird consequence that the skeleton is not a function of the
|
||||||
|
// casefolded identifier --- therefore it must always be computed
|
||||||
|
// from the original (unfolded) identifier and stored/tracked separately from the
|
||||||
|
// casefolded identifier.
|
||||||
|
func Skeleton(name string) (string, error) {
|
||||||
|
// XXX the confusables table includes some, but not all, fullwidth->standard
|
||||||
|
// mappings for latin characters. do a pass of explicit width folding,
|
||||||
|
// same as PRECIS:
|
||||||
|
name = width.Fold.String(name)
|
||||||
|
|
||||||
|
name = confusables.SkeletonTweaked(name)
|
||||||
|
|
||||||
|
// internationalized lowercasing for skeletons; this is much more lenient than
|
||||||
|
// Casefold. In particular, skeletons are expected to mix scripts (which may
|
||||||
|
// violate the bidi rule). We also don't care if they contain runes
|
||||||
|
// that are disallowed by PRECIS, because every identifier must independently
|
||||||
|
// pass PRECIS --- we are just further canonicalizing the skeleton.
|
||||||
|
return cases.Fold().String(name), nil
|
||||||
|
}
|
||||||
156
irc/i18n/strings_test.go
Normal file
156
irc/i18n/strings_test.go
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
//go:build i18n
|
||||||
|
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func validFoldTester(first, second string, equal bool, folder func(string) (string, error), t *testing.T) {
|
||||||
|
firstFolded, err := folder(first)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
secondFolded, err := folder(second)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
foundEqual := firstFolded == secondFolded
|
||||||
|
if foundEqual != equal {
|
||||||
|
t.Errorf("%s and %s: expected equality %t, but got %t", first, second, equal, foundEqual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldPermissive(t *testing.T) {
|
||||||
|
tester := func(first, second string, equal bool) {
|
||||||
|
validFoldTester(first, second, equal, foldPermissive, t)
|
||||||
|
}
|
||||||
|
tester("SHIVARAM", "shivaram", true)
|
||||||
|
tester("shIvaram", "shivaraM", true)
|
||||||
|
tester("shivaram", "DAN-", false)
|
||||||
|
tester("dolph🐬n", "DOLPH🐬n", true)
|
||||||
|
tester("dolph🐬n", "dolph💻n", false)
|
||||||
|
tester("9FRONT", "9front", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldPermissiveInvalid(t *testing.T) {
|
||||||
|
_, err := foldPermissive("a\tb")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("whitespace should be invalid in identifiers")
|
||||||
|
}
|
||||||
|
_, err = foldPermissive("a\x00b")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("the null byte should be invalid in identifiers")
|
||||||
|
}
|
||||||
|
_, err = foldPermissive("a b")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("space should be invalid in identifiers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldPermissiveNormalization(t *testing.T) {
|
||||||
|
tester := func(first, second string, equal bool) {
|
||||||
|
validFoldTester(first, second, equal, foldPermissive, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// case folding should work on non-ASCII letters
|
||||||
|
tester("Ω", "ω", true) // Greek capital/small omega
|
||||||
|
tester("Ñoño", "ñoño", true) // Spanish precomposed tilde-n, upper vs lower
|
||||||
|
tester("中文", "中文", true) // CJK (no case distinction)
|
||||||
|
tester("中文", "English", false) // different scripts, not equal
|
||||||
|
|
||||||
|
// NFC-encoded input: "É" (U+00C9) and "é" (U+00E9) should fold equal
|
||||||
|
// NFD normalization before case folding ensures composed chars are handled
|
||||||
|
tester("\u00c9l\u00e8ve", "\u00e9l\u00e8ve", true) // Élève vs élève
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldASCII(t *testing.T) {
|
||||||
|
tester := func(first, second string, equal bool) {
|
||||||
|
validFoldTester(first, second, equal, foldASCII, t)
|
||||||
|
}
|
||||||
|
tester("shivaram", "SHIVARAM", true)
|
||||||
|
tester("X|Y", "x|y", true)
|
||||||
|
tester("a != b", "A != B", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldASCIIInvalid(t *testing.T) {
|
||||||
|
_, err := foldASCII("\x01")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("control characters should be invalid in identifiers")
|
||||||
|
}
|
||||||
|
_, err = foldASCII("\x7F")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("control characters should be invalid in identifiers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldRFC1459(t *testing.T) {
|
||||||
|
folder := func(str string) (string, error) {
|
||||||
|
return foldRFC1459(str, false)
|
||||||
|
}
|
||||||
|
tester := func(first, second string, equal bool) {
|
||||||
|
validFoldTester(first, second, equal, folder, t)
|
||||||
|
}
|
||||||
|
tester("shivaram", "SHIVARAM", true)
|
||||||
|
tester("shivaram[a]", "shivaram{a}", true)
|
||||||
|
tester("shivaram\\a]", "shivaram{a}", false)
|
||||||
|
tester("shivaram\\a]", "shivaram|a}", true)
|
||||||
|
tester("shivaram~a]", "shivaram^a}", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldRFC1459Strict(t *testing.T) {
|
||||||
|
folder := func(str string) (string, error) {
|
||||||
|
return foldRFC1459(str, true)
|
||||||
|
}
|
||||||
|
tester := func(first, second string, equal bool) {
|
||||||
|
validFoldTester(first, second, equal, folder, t)
|
||||||
|
}
|
||||||
|
tester("shivaram", "SHIVARAM", true)
|
||||||
|
tester("shivaram[a]", "shivaram{a}", true)
|
||||||
|
tester("shivaram\\a]", "shivaram{a}", false)
|
||||||
|
tester("shivaram\\a]", "shivaram|a}", true)
|
||||||
|
tester("shivaram~a]", "shivaram^a}", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkeleton(t *testing.T) {
|
||||||
|
skeleton := func(str string) string {
|
||||||
|
skel, err := Skeleton(str)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
return skel
|
||||||
|
}
|
||||||
|
|
||||||
|
if skeleton("warning") == skeleton("waming") {
|
||||||
|
t.Errorf("Oragono shouldn't consider rn confusable with m")
|
||||||
|
}
|
||||||
|
|
||||||
|
if skeleton("Phi|ip") != "philip" {
|
||||||
|
t.Errorf("but we still consider pipe confusable with l")
|
||||||
|
}
|
||||||
|
|
||||||
|
if skeleton("smt") != skeleton("smt") {
|
||||||
|
t.Errorf("fullwidth characters should skeletonize to plain old ascii characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
if skeleton("SMT") != skeleton("smt") {
|
||||||
|
t.Errorf("after skeletonizing, we should casefold")
|
||||||
|
}
|
||||||
|
|
||||||
|
if skeleton("smt") != skeleton("smt") {
|
||||||
|
t.Errorf("our friend lover successfully tricked the skeleton algorithm!")
|
||||||
|
}
|
||||||
|
|
||||||
|
if skeleton("еvan") != "evan" {
|
||||||
|
t.Errorf("we must protect against cyrillic homoglyph attacks")
|
||||||
|
}
|
||||||
|
|
||||||
|
if skeleton("еmily") != skeleton("emily") {
|
||||||
|
t.Errorf("we must protect against cyrillic homoglyph attacks")
|
||||||
|
}
|
||||||
|
|
||||||
|
if skeleton("РОТАТО") != "potato" {
|
||||||
|
t.Errorf("we must protect against cyrillic homoglyph attacks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// should not raise an error:
|
||||||
|
skeleton("けらんぐ")
|
||||||
|
}
|
||||||
18
irc/i18n/stub.go
Normal file
18
irc/i18n/stub.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
//go:build !i18n
|
||||||
|
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
const (
|
||||||
|
Enabled = false
|
||||||
|
|
||||||
|
DefaultCasemapping = CasemappingASCII
|
||||||
|
)
|
||||||
|
|
||||||
|
func CasefoldWithSetting(str string, setting Casemapping) (string, error) {
|
||||||
|
return foldASCII(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Skeleton(str string) (string, error) {
|
||||||
|
// identity function is fine because we independently case-normalize in Casefold
|
||||||
|
return str, nil
|
||||||
|
}
|
||||||
@ -9,10 +9,15 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tidwall/buntdb"
|
"github.com/tidwall/buntdb"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/bunt"
|
||||||
|
"github.com/ergochat/ergo/irc/datastore"
|
||||||
|
"github.com/ergochat/ergo/irc/modes"
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
"github.com/ergochat/ergo/irc/utils"
|
||||||
|
"github.com/ergochat/ergo/irc/webpush"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -20,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 = 22
|
importDBSchemaVersion = 24
|
||||||
)
|
)
|
||||||
|
|
||||||
type userImport struct {
|
type userImport struct {
|
||||||
@ -54,8 +59,8 @@ type databaseImport struct {
|
|||||||
Channels map[string]channelImport
|
Channels map[string]channelImport
|
||||||
}
|
}
|
||||||
|
|
||||||
func serializeAmodes(raw map[string]string, validCfUsernames utils.HashSet[string]) (result []byte, err error) {
|
func convertAmodes(raw map[string]string, validCfUsernames utils.HashSet[string]) (result map[string]modes.Mode, err error) {
|
||||||
processed := make(map[string]int, len(raw))
|
result = make(map[string]modes.Mode)
|
||||||
for accountName, mode := range raw {
|
for accountName, mode := range raw {
|
||||||
if len(mode) != 1 {
|
if len(mode) != 1 {
|
||||||
return nil, fmt.Errorf("invalid mode %s for account %s", mode, accountName)
|
return nil, fmt.Errorf("invalid mode %s for account %s", mode, accountName)
|
||||||
@ -64,10 +69,9 @@ func serializeAmodes(raw map[string]string, validCfUsernames utils.HashSet[strin
|
|||||||
if err != nil || !validCfUsernames.Has(cfname) {
|
if err != nil || !validCfUsernames.Has(cfname) {
|
||||||
log.Printf("skipping invalid amode recipient %s\n", accountName)
|
log.Printf("skipping invalid amode recipient %s\n", accountName)
|
||||||
} else {
|
} else {
|
||||||
processed[cfname] = int(mode[0])
|
result[cfname] = modes.Mode(mode[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result, err = json.Marshal(processed)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,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)
|
||||||
@ -147,8 +160,9 @@ func doImportDBGeneric(config *Config, dbImport databaseImport, credsType Creden
|
|||||||
cfUsernames.Add(cfUsername)
|
cfUsernames.Add(cfUsername)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO fix this:
|
||||||
for chname, chInfo := range dbImport.Channels {
|
for chname, chInfo := range dbImport.Channels {
|
||||||
cfchname, err := CasefoldChannel(chname)
|
_, err := CasefoldChannel(chname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("invalid channel name %s: %v", chname, err)
|
log.Printf("invalid channel name %s: %v", chname, err)
|
||||||
continue
|
continue
|
||||||
@ -158,43 +172,42 @@ func doImportDBGeneric(config *Config, dbImport databaseImport, credsType Creden
|
|||||||
log.Printf("invalid founder %s for channel %s: %v", chInfo.Founder, chname, err)
|
log.Printf("invalid founder %s for channel %s: %v", chInfo.Founder, chname, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tx.Set(fmt.Sprintf(keyChannelExists, cfchname), "1", nil)
|
var regInfo RegisteredChannel
|
||||||
tx.Set(fmt.Sprintf(keyChannelName, cfchname), chname, nil)
|
regInfo.Name = chname
|
||||||
tx.Set(fmt.Sprintf(keyChannelRegTime, cfchname), strconv.FormatInt(chInfo.RegisteredAt, 10), nil)
|
regInfo.UUID = utils.GenerateUUIDv4()
|
||||||
tx.Set(fmt.Sprintf(keyChannelFounder, cfchname), cffounder, nil)
|
regInfo.Founder = cffounder
|
||||||
accountChannelsKey := fmt.Sprintf(keyAccountChannels, cffounder)
|
regInfo.RegisteredAt = time.Unix(0, chInfo.RegisteredAt).UTC()
|
||||||
founderChannels, fcErr := tx.Get(accountChannelsKey)
|
|
||||||
if fcErr != nil || founderChannels == "" {
|
|
||||||
founderChannels = cfchname
|
|
||||||
} else {
|
|
||||||
founderChannels = fmt.Sprintf("%s,%s", founderChannels, cfchname)
|
|
||||||
}
|
|
||||||
tx.Set(accountChannelsKey, founderChannels, nil)
|
|
||||||
if chInfo.Topic != "" {
|
if chInfo.Topic != "" {
|
||||||
tx.Set(fmt.Sprintf(keyChannelTopic, cfchname), chInfo.Topic, nil)
|
regInfo.Topic = chInfo.Topic
|
||||||
tx.Set(fmt.Sprintf(keyChannelTopicSetTime, cfchname), strconv.FormatInt(chInfo.TopicSetAt, 10), nil)
|
regInfo.TopicSetBy = chInfo.TopicSetBy
|
||||||
tx.Set(fmt.Sprintf(keyChannelTopicSetBy, cfchname), chInfo.TopicSetBy, nil)
|
regInfo.TopicSetTime = time.Unix(0, chInfo.TopicSetAt).UTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chInfo.Amode) != 0 {
|
if len(chInfo.Amode) != 0 {
|
||||||
m, err := serializeAmodes(chInfo.Amode, cfUsernames)
|
m, err := convertAmodes(chInfo.Amode, cfUsernames)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
tx.Set(fmt.Sprintf(keyChannelAccountToUMode, cfchname), string(m), nil)
|
regInfo.AccountToUMode = m
|
||||||
} else {
|
} else {
|
||||||
log.Printf("couldn't serialize amodes for %s: %v", chname, err)
|
log.Printf("couldn't process amodes for %s: %v", chname, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.Set(fmt.Sprintf(keyChannelModes, cfchname), chInfo.Modes, nil)
|
for _, mode := range chInfo.Modes {
|
||||||
if chInfo.Key != "" {
|
regInfo.Modes = append(regInfo.Modes, modes.Mode(mode))
|
||||||
tx.Set(fmt.Sprintf(keyChannelPassword, cfchname), chInfo.Key, nil)
|
|
||||||
}
|
}
|
||||||
|
regInfo.Key = chInfo.Key
|
||||||
if chInfo.Limit > 0 {
|
if chInfo.Limit > 0 {
|
||||||
tx.Set(fmt.Sprintf(keyChannelUserLimit, cfchname), strconv.Itoa(chInfo.Limit), nil)
|
regInfo.UserLimit = chInfo.Limit
|
||||||
}
|
}
|
||||||
if chInfo.Forward != "" {
|
if chInfo.Forward != "" {
|
||||||
if _, err := CasefoldChannel(chInfo.Forward); err == nil {
|
if _, err := CasefoldChannel(chInfo.Forward); err == nil {
|
||||||
tx.Set(fmt.Sprintf(keyChannelForward, cfchname), chInfo.Forward, nil)
|
regInfo.Forward = chInfo.Forward
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if j, err := json.Marshal(regInfo); err == nil {
|
||||||
|
tx.Set(bunt.BuntKey(datastore.TableChannels, regInfo.UUID), string(j), nil)
|
||||||
|
} else {
|
||||||
|
log.Printf("couldn't serialize channel %s: %v", chname, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if warnSkeletons {
|
if warnSkeletons {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ package irc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@ -93,21 +94,25 @@ func (cc *IRCStreamConn) Close() (err error) {
|
|||||||
// IRCWSConn is an IRCConn over a websocket.
|
// IRCWSConn is an IRCConn over a websocket.
|
||||||
type IRCWSConn struct {
|
type IRCWSConn struct {
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
|
buf []byte
|
||||||
binary bool
|
binary bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIRCWSConn(conn *websocket.Conn) IRCWSConn {
|
func NewIRCWSConn(conn *websocket.Conn) *IRCWSConn {
|
||||||
binary := conn.Subprotocol() == "binary.ircv3.net"
|
return &IRCWSConn{
|
||||||
return IRCWSConn{conn: conn, binary: binary}
|
conn: conn,
|
||||||
|
binary: conn.Subprotocol() == "binary.ircv3.net",
|
||||||
|
buf: make([]byte, initialBufferSize),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wc IRCWSConn) UnderlyingConn() *utils.WrappedConn {
|
func (wc *IRCWSConn) UnderlyingConn() *utils.WrappedConn {
|
||||||
// just assume that the type is OK
|
// just assume that the type is OK
|
||||||
wConn, _ := wc.conn.UnderlyingConn().(*utils.WrappedConn)
|
wConn, _ := wc.conn.UnderlyingConn().(*utils.WrappedConn)
|
||||||
return wConn
|
return wConn
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wc IRCWSConn) WriteLine(buf []byte) (err error) {
|
func (wc *IRCWSConn) WriteLine(buf []byte) (err error) {
|
||||||
buf = bytes.TrimSuffix(buf, crlf)
|
buf = bytes.TrimSuffix(buf, crlf)
|
||||||
// #1483: if we have websockets at all, then we're enforcing utf8
|
// #1483: if we have websockets at all, then we're enforcing utf8
|
||||||
messageType := websocket.TextMessage
|
messageType := websocket.TextMessage
|
||||||
@ -117,7 +122,7 @@ func (wc IRCWSConn) WriteLine(buf []byte) (err error) {
|
|||||||
return wc.conn.WriteMessage(messageType, buf)
|
return wc.conn.WriteMessage(messageType, buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wc IRCWSConn) WriteLines(buffers [][]byte) (err error) {
|
func (wc *IRCWSConn) WriteLines(buffers [][]byte) (err error) {
|
||||||
for _, buf := range buffers {
|
for _, buf := range buffers {
|
||||||
err = wc.WriteLine(buf)
|
err = wc.WriteLine(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -127,20 +132,47 @@ func (wc IRCWSConn) WriteLines(buffers [][]byte) (err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wc IRCWSConn) ReadLine() (line []byte, err error) {
|
func (wc *IRCWSConn) ReadLine() (line []byte, err error) {
|
||||||
messageType, line, err := wc.conn.ReadMessage()
|
_, reader, err := wc.conn.NextReader()
|
||||||
if err == nil {
|
switch err {
|
||||||
if messageType == websocket.BinaryMessage && !utf8.Valid(line) {
|
case nil:
|
||||||
|
// OK
|
||||||
|
case websocket.ErrReadLimit:
|
||||||
|
return line, ircreader.ErrReadQ
|
||||||
|
default:
|
||||||
|
return line, err
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err = wc.readFull(reader)
|
||||||
|
switch err {
|
||||||
|
case io.ErrUnexpectedEOF, io.EOF:
|
||||||
|
// these are OK. io.ErrUnexpectedEOF is the good case:
|
||||||
|
// it means we read the full message and it consumed less than the full wc.buf
|
||||||
|
if !utf8.Valid(line) {
|
||||||
return line, errInvalidUtf8
|
return line, errInvalidUtf8
|
||||||
}
|
}
|
||||||
return line, nil
|
return line, nil
|
||||||
} else if err == websocket.ErrReadLimit {
|
case nil, websocket.ErrReadLimit:
|
||||||
|
// nil means we filled wc.buf without exhausting the reader:
|
||||||
return line, ircreader.ErrReadQ
|
return line, ircreader.ErrReadQ
|
||||||
} else {
|
default:
|
||||||
return line, err
|
return line, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wc IRCWSConn) Close() (err error) {
|
func (wc *IRCWSConn) readFull(reader io.Reader) (line []byte, err error) {
|
||||||
|
// XXX this is io.ReadFull with a single attempt to resize upwards
|
||||||
|
n, err := io.ReadFull(reader, wc.buf)
|
||||||
|
if err == nil && len(wc.buf) < maxReadQBytes() {
|
||||||
|
newBuf := make([]byte, maxReadQBytes())
|
||||||
|
copy(newBuf, wc.buf[:n])
|
||||||
|
wc.buf = newBuf
|
||||||
|
n2, err := io.ReadFull(reader, wc.buf[n:])
|
||||||
|
return wc.buf[:n+n2], err
|
||||||
|
}
|
||||||
|
return wc.buf[:n], err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *IRCWSConn) Close() (err error) {
|
||||||
return wc.conn.Close()
|
return wc.conn.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
// make sure we get a sorted list of tokens, needed for tests and looks nice
|
tokens = append(tokens, token)
|
||||||
var tokens sort.StringSlice
|
} else {
|
||||||
for name := range il.Tokens {
|
err = tokenErr
|
||||||
tokens = append(tokens, name)
|
}
|
||||||
}
|
}
|
||||||
sort.Sort(tokens)
|
// make sure we get a sorted list of tokens, needed for tests and looks nice
|
||||||
|
slices.Sort(tokens)
|
||||||
|
|
||||||
for _, name := range tokens {
|
var cache []string // Tokens in current line
|
||||||
token := getTokenString(name, il.Tokens[name])
|
var length int // Length of the current line
|
||||||
if token[0] == ':' || strings.Contains(token, " ") {
|
|
||||||
err = fmt.Errorf("bad isupport token (cannot contain spaces or start with :): %s", token)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(token)+length <= maxLastArgLength {
|
for _, token := range tokens {
|
||||||
// account for the space separating tokens
|
// account for the space separating tokens
|
||||||
if len(cache) > 0 {
|
if len(cache) == maxParameters || (len(token)+1)+length > maxPayloadLength {
|
||||||
length++
|
|
||||||
}
|
|
||||||
cache = append(cache, token)
|
|
||||||
length += len(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cache) == 13 || len(token)+length >= maxLastArgLength {
|
|
||||||
il.CachedReply = append(il.CachedReply, cache)
|
il.CachedReply = append(il.CachedReply, cache)
|
||||||
cache = make([]string, 0)
|
cache = nil
|
||||||
length = 0
|
length = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(cache) > 0 {
|
||||||
|
length++
|
||||||
|
}
|
||||||
|
length += len(token)
|
||||||
|
cache = append(cache, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cache) > 0 {
|
if len(cache) > 0 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
121
irc/legacy.go
121
irc/legacy.go
@ -4,7 +4,15 @@ package irc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tidwall/buntdb"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/modes"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -25,3 +33,116 @@ func decodeLegacyPasswordHash(hash string) ([]byte, error) {
|
|||||||
return nil, errInvalidPasswordHash
|
return nil, errInvalidPasswordHash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// legacy channel registration code
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyChannelExists = "channel.exists %s"
|
||||||
|
keyChannelName = "channel.name %s" // stores the 'preferred name' of the channel, not casemapped
|
||||||
|
keyChannelRegTime = "channel.registered.time %s"
|
||||||
|
keyChannelFounder = "channel.founder %s"
|
||||||
|
keyChannelTopic = "channel.topic %s"
|
||||||
|
keyChannelTopicSetBy = "channel.topic.setby %s"
|
||||||
|
keyChannelTopicSetTime = "channel.topic.settime %s"
|
||||||
|
keyChannelBanlist = "channel.banlist %s"
|
||||||
|
keyChannelExceptlist = "channel.exceptlist %s"
|
||||||
|
keyChannelInvitelist = "channel.invitelist %s"
|
||||||
|
keyChannelPassword = "channel.key %s"
|
||||||
|
keyChannelModes = "channel.modes %s"
|
||||||
|
keyChannelAccountToUMode = "channel.accounttoumode %s"
|
||||||
|
keyChannelUserLimit = "channel.userlimit %s"
|
||||||
|
keyChannelSettings = "channel.settings %s"
|
||||||
|
keyChannelForward = "channel.forward %s"
|
||||||
|
|
||||||
|
keyChannelPurged = "channel.purged %s"
|
||||||
|
)
|
||||||
|
|
||||||
|
func deleteLegacyChannel(tx *buntdb.Tx, nameCasefolded string) {
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelExists, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelName, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelRegTime, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelFounder, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelTopic, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelTopicSetBy, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelTopicSetTime, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelBanlist, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelExceptlist, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelInvitelist, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelPassword, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelModes, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelAccountToUMode, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelUserLimit, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelSettings, nameCasefolded))
|
||||||
|
tx.Delete(fmt.Sprintf(keyChannelForward, nameCasefolded))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadLegacyChannel(tx *buntdb.Tx, nameCasefolded string) (info RegisteredChannel, err error) {
|
||||||
|
channelKey := nameCasefolded
|
||||||
|
// nice to have: do all JSON (de)serialization outside of the buntdb transaction
|
||||||
|
_, dberr := tx.Get(fmt.Sprintf(keyChannelExists, channelKey))
|
||||||
|
if dberr == buntdb.ErrNotFound {
|
||||||
|
// chan does not already exist, return
|
||||||
|
err = errNoSuchChannel
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// channel exists, load it
|
||||||
|
name, _ := tx.Get(fmt.Sprintf(keyChannelName, channelKey))
|
||||||
|
regTime, _ := tx.Get(fmt.Sprintf(keyChannelRegTime, channelKey))
|
||||||
|
regTimeInt, _ := strconv.ParseInt(regTime, 10, 64)
|
||||||
|
founder, _ := tx.Get(fmt.Sprintf(keyChannelFounder, channelKey))
|
||||||
|
topic, _ := tx.Get(fmt.Sprintf(keyChannelTopic, channelKey))
|
||||||
|
topicSetBy, _ := tx.Get(fmt.Sprintf(keyChannelTopicSetBy, channelKey))
|
||||||
|
var topicSetTime time.Time
|
||||||
|
topicSetTimeStr, _ := tx.Get(fmt.Sprintf(keyChannelTopicSetTime, channelKey))
|
||||||
|
if topicSetTimeInt, topicSetTimeErr := strconv.ParseInt(topicSetTimeStr, 10, 64); topicSetTimeErr == nil {
|
||||||
|
topicSetTime = time.Unix(0, topicSetTimeInt).UTC()
|
||||||
|
}
|
||||||
|
password, _ := tx.Get(fmt.Sprintf(keyChannelPassword, channelKey))
|
||||||
|
modeString, _ := tx.Get(fmt.Sprintf(keyChannelModes, channelKey))
|
||||||
|
userLimitString, _ := tx.Get(fmt.Sprintf(keyChannelUserLimit, channelKey))
|
||||||
|
forward, _ := tx.Get(fmt.Sprintf(keyChannelForward, channelKey))
|
||||||
|
banlistString, _ := tx.Get(fmt.Sprintf(keyChannelBanlist, channelKey))
|
||||||
|
exceptlistString, _ := tx.Get(fmt.Sprintf(keyChannelExceptlist, channelKey))
|
||||||
|
invitelistString, _ := tx.Get(fmt.Sprintf(keyChannelInvitelist, channelKey))
|
||||||
|
accountToUModeString, _ := tx.Get(fmt.Sprintf(keyChannelAccountToUMode, channelKey))
|
||||||
|
settingsString, _ := tx.Get(fmt.Sprintf(keyChannelSettings, channelKey))
|
||||||
|
|
||||||
|
modeSlice := make([]modes.Mode, len(modeString))
|
||||||
|
for i, mode := range modeString {
|
||||||
|
modeSlice[i] = modes.Mode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
userLimit, _ := strconv.Atoi(userLimitString)
|
||||||
|
|
||||||
|
var banlist map[string]MaskInfo
|
||||||
|
_ = json.Unmarshal([]byte(banlistString), &banlist)
|
||||||
|
var exceptlist map[string]MaskInfo
|
||||||
|
_ = json.Unmarshal([]byte(exceptlistString), &exceptlist)
|
||||||
|
var invitelist map[string]MaskInfo
|
||||||
|
_ = json.Unmarshal([]byte(invitelistString), &invitelist)
|
||||||
|
accountToUMode := make(map[string]modes.Mode)
|
||||||
|
_ = json.Unmarshal([]byte(accountToUModeString), &accountToUMode)
|
||||||
|
|
||||||
|
var settings ChannelSettings
|
||||||
|
_ = json.Unmarshal([]byte(settingsString), &settings)
|
||||||
|
|
||||||
|
info = RegisteredChannel{
|
||||||
|
Name: name,
|
||||||
|
RegisteredAt: time.Unix(0, regTimeInt).UTC(),
|
||||||
|
Founder: founder,
|
||||||
|
Topic: topic,
|
||||||
|
TopicSetBy: topicSetBy,
|
||||||
|
TopicSetTime: topicSetTime,
|
||||||
|
Key: password,
|
||||||
|
Modes: modeSlice,
|
||||||
|
Bans: banlist,
|
||||||
|
Excepts: exceptlist,
|
||||||
|
Invites: invitelist,
|
||||||
|
AccountToUMode: accountToUMode,
|
||||||
|
UserLimit: int(userLimit),
|
||||||
|
Settings: settings,
|
||||||
|
Forward: forward,
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
@ -95,8 +99,13 @@ func (nl *NetListener) serve() {
|
|||||||
// hand off the connection
|
// hand off the connection
|
||||||
wConn, ok := conn.(*utils.WrappedConn)
|
wConn, ok := conn.(*utils.WrappedConn)
|
||||||
if ok {
|
if ok {
|
||||||
confirmProxyData(wConn, "", "", "", nl.server.Config())
|
if wConn.ProxyError == nil {
|
||||||
go nl.server.RunClient(NewIRCStreamConn(wConn))
|
confirmProxyData(wConn, "", "", "", nl.server.Config())
|
||||||
|
go nl.server.RunClient(NewIRCStreamConn(wConn))
|
||||||
|
} else {
|
||||||
|
nl.server.logger.Error("internal", "PROXY protocol error", nl.addr, wConn.ProxyError.Error())
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
nl.server.logger.Error("internal", "invalid connection type", nl.addr)
|
nl.server.logger.Error("internal", "invalid connection type", nl.addr)
|
||||||
}
|
}
|
||||||
@ -181,6 +190,13 @@ func (wl *WSListener) handle(w http.ResponseWriter, r *http.Request) {
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if wConn.ProxyError != nil {
|
||||||
|
// actually the connection is likely corrupted, so probably Upgrade()
|
||||||
|
// would have already failed
|
||||||
|
wl.server.logger.Error("internal", "PROXY protocol error on websocket", wl.addr, wConn.ProxyError.Error())
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
confirmProxyData(wConn, remoteAddr, xff, xfp, config)
|
confirmProxyData(wConn, remoteAddr, xff, xfp, config)
|
||||||
|
|
||||||
@ -204,10 +220,10 @@ func confirmProxyData(conn *utils.WrappedConn, remoteAddr, xForwardedFor, xForwa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if conn.Config.TLSConfig != nil || conn.Config.Tor {
|
if conn.TLS || conn.Tor {
|
||||||
// we terminated our own encryption:
|
// we terminated our own encryption:
|
||||||
conn.Secure = true
|
conn.Secure = true
|
||||||
} else if !conn.Config.WebSocket {
|
} else if !conn.WebSocket {
|
||||||
// plaintext normal connection: loopback and secureNets are secure
|
// plaintext normal connection: loopback and secureNets are secure
|
||||||
realIP := utils.AddrToIP(conn.RemoteAddr())
|
realIP := utils.AddrToIP(conn.RemoteAddr())
|
||||||
conn.Secure = realIP.IsLoopback() || utils.IPInNets(realIP, config.Server.secureNets)
|
conn.Secure = realIP.IsLoopback() || utils.IPInNets(realIP, config.Server.secureNets)
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
@ -79,8 +79,7 @@ func (m *MessageCache) Initialize(server *Server, serverTime time.Time, msgid st
|
|||||||
m.params = params
|
m.params = params
|
||||||
|
|
||||||
var msg ircmsg.Message
|
var msg ircmsg.Message
|
||||||
config := server.Config()
|
if forceTrailing(server.Config(), command) {
|
||||||
if config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing[command] {
|
|
||||||
msg.ForceTrailing()
|
msg.ForceTrailing()
|
||||||
}
|
}
|
||||||
msg.Source = nickmask
|
msg.Source = nickmask
|
||||||
@ -111,8 +110,7 @@ func (m *MessageCache) InitializeSplitMessage(server *Server, nickmask, accountN
|
|||||||
m.target = target
|
m.target = target
|
||||||
m.splitMessage = message
|
m.splitMessage = message
|
||||||
|
|
||||||
config := server.Config()
|
forceTrailing := forceTrailing(server.Config(), command)
|
||||||
forceTrailing := config.Server.Compatibility.forceTrailing && commandsThatMustUseTrailing[command]
|
|
||||||
|
|
||||||
if message.Is512() {
|
if message.Is512() {
|
||||||
isTagmsg := command == "TAGMSG"
|
isTagmsg := command == "TAGMSG"
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
irc/modes.go
25
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
|
||||||
@ -248,9 +251,11 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
|
|||||||
switch change.Op {
|
switch change.Op {
|
||||||
case modes.Add:
|
case modes.Add:
|
||||||
val, err := strconv.Atoi(change.Arg)
|
val, err := strconv.Atoi(change.Arg)
|
||||||
if err == nil {
|
if err == nil && val > 0 {
|
||||||
channel.setUserLimit(val)
|
channel.setUserLimit(val)
|
||||||
applied = append(applied, change)
|
applied = append(applied, change)
|
||||||
|
} else {
|
||||||
|
rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), client.t("+l user limit value must be an integer between 1 and 2147483647, expressed in base 10"))
|
||||||
}
|
}
|
||||||
|
|
||||||
case modes.Remove:
|
case modes.Remove:
|
||||||
@ -263,9 +268,9 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
|
|||||||
case modes.Add:
|
case modes.Add:
|
||||||
ch := client.server.channels.Get(change.Arg)
|
ch := client.server.channels.Get(change.Arg)
|
||||||
if ch == nil {
|
if ch == nil {
|
||||||
rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), fmt.Sprintf(client.t("No such channel")))
|
rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), client.t("No such channel"))
|
||||||
} else if ch == channel {
|
} else if ch == channel {
|
||||||
rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), fmt.Sprintf(client.t("You can't forward a channel to itself")))
|
rb.Add(nil, client.server.name, ERR_INVALIDMODEPARAM, details.nick, chname, string(change.Mode), utils.SafeErrorParam(change.Arg), client.t("You can't forward a channel to itself"))
|
||||||
} else {
|
} else {
|
||||||
if isSamode || ch.ClientIsAtLeast(client, modes.ChannelOperator) {
|
if isSamode || ch.ClientIsAtLeast(client, modes.ChannelOperator) {
|
||||||
change.Arg = ch.Name()
|
change.Arg = ch.Name()
|
||||||
@ -313,11 +318,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 +345,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 {
|
changes = append(changes, change)
|
||||||
if rune(supportedMode) == mode {
|
} else {
|
||||||
isKnown = true
|
unknown = append(unknown, mode)
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if !isKnown {
|
|
||||||
unknown[mode] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,10 +228,7 @@ func ParseUserModeChanges(params ...string) (ModeChanges, map[rune]bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseChannelModeChanges returns the valid changes, and the list of unknown chars.
|
// ParseChannelModeChanges returns the valid changes, and the list of unknown chars.
|
||||||
func ParseChannelModeChanges(params ...string) (ModeChanges, map[rune]bool) {
|
func ParseChannelModeChanges(params ...string) (changes ModeChanges, unknown []rune) {
|
||||||
changes := make(ModeChanges, 0)
|
|
||||||
unknown := make(map[rune]bool)
|
|
||||||
|
|
||||||
op := List
|
op := List
|
||||||
|
|
||||||
if 0 < len(params) {
|
if 0 < len(params) {
|
||||||
@ -304,25 +290,11 @@ func ParseChannelModeChanges(params ...string) (ModeChanges, map[rune]bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isKnown bool
|
if slices.Contains(SupportedChannelModes, Mode(mode)) || slices.Contains(ChannelUserModes, Mode(mode)) {
|
||||||
for _, supportedMode := range SupportedChannelModes {
|
changes = append(changes, change)
|
||||||
if rune(supportedMode) == mode {
|
} else {
|
||||||
isKnown = true
|
unknown = append(unknown, mode)
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, supportedMode := range ChannelUserModes {
|
|
||||||
if rune(supportedMode) == mode {
|
|
||||||
isKnown = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !isKnown {
|
|
||||||
unknown[mode] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,6 +317,10 @@ func NewModeSet() *ModeSet {
|
|||||||
return &set
|
return &set
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (set *ModeSet) Clear() {
|
||||||
|
utils.BitsetClear(set[:])
|
||||||
|
}
|
||||||
|
|
||||||
// test whether `mode` is set
|
// test whether `mode` is set
|
||||||
func (set *ModeSet) HasMode(mode Mode) bool {
|
func (set *ModeSet) HasMode(mode Mode) bool {
|
||||||
if set == nil {
|
if set == nil {
|
||||||
@ -424,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}
|
||||||
@ -461,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{
|
||||||
|
|||||||
@ -28,17 +28,21 @@ func (mm *MonitorManager) Initialize() {
|
|||||||
|
|
||||||
// AddMonitors adds clients using extended-monitor monitoring `client`'s nick to the passed user set.
|
// AddMonitors adds clients using extended-monitor monitoring `client`'s nick to the passed user set.
|
||||||
func (manager *MonitorManager) AddMonitors(users utils.HashSet[*Session], cfnick string, capabs ...caps.Capability) {
|
func (manager *MonitorManager) AddMonitors(users utils.HashSet[*Session], cfnick string, capabs ...caps.Capability) {
|
||||||
|
// technically, we should check extended-monitor here, but it's not really necessary
|
||||||
|
// since clients will ignore AWAY, ACCOUNT, CHGHOST, and SETNAME for users
|
||||||
|
// they're not tracking
|
||||||
|
|
||||||
manager.RLock()
|
manager.RLock()
|
||||||
defer manager.RUnlock()
|
defer manager.RUnlock()
|
||||||
for session := range manager.watchedby[cfnick] {
|
for session := range manager.watchedby[cfnick] {
|
||||||
if session.capabilities.Has(caps.ExtendedMonitor) && session.capabilities.HasAll(capabs...) {
|
if session.capabilities.HasAll(capabs...) {
|
||||||
users.Add(session)
|
users.Add(session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertAbout alerts everyone monitoring `client`'s nick that `client` is now {on,off}line.
|
// AlertAbout alerts everyone monitoring `client`'s nick that `client` is now {on,off}line.
|
||||||
func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool) {
|
func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool, client *Client) {
|
||||||
var watchers []*Session
|
var watchers []*Session
|
||||||
// safely copy the list of clients watching our nick
|
// safely copy the list of clients watching our nick
|
||||||
manager.RLock()
|
manager.RLock()
|
||||||
@ -52,8 +56,21 @@ func (manager *MonitorManager) AlertAbout(nick, cfnick string, online bool) {
|
|||||||
command = RPL_MONONLINE
|
command = RPL_MONONLINE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var metadata map[string]string
|
||||||
|
if online && client != nil {
|
||||||
|
metadata = client.ListMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
for _, session := range watchers {
|
for _, session := range watchers {
|
||||||
session.Send(nil, session.client.server.name, command, session.client.Nick(), nick)
|
session.Send(nil, session.client.server.name, command, session.client.Nick(), nick)
|
||||||
|
|
||||||
|
if metadata != nil && session.capabilities.Has(caps.Metadata) {
|
||||||
|
for key := range session.MetadataSubscriptions() {
|
||||||
|
if val, ok := metadata[key]; ok {
|
||||||
|
session.Send(nil, client.server.name, "METADATA", nick, key, "*", val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maximum length in bytes of any message target (nickname or channel name) in its
|
||||||
|
// canonicalized (i.e., casefolded) state:
|
||||||
|
MaxTargetLength = 64
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// these are intended to be written directly into the config file:
|
// these are intended to be written directly into the config file:
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
//go:build mysql
|
||||||
|
|
||||||
// Copyright (c) 2020 Shivaram Lingamneni
|
// Copyright (c) 2020 Shivaram Lingamneni
|
||||||
// released under the MIT license
|
// released under the MIT license
|
||||||
|
|
||||||
@ -7,10 +9,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@ -22,14 +24,9 @@ import (
|
|||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
ErrDisallowed = errors.New("disallowed")
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// maximum length in bytes of any message target (nickname or channel name) in its
|
// Enabled is true when MySQL support is compiled in
|
||||||
// canonicalized (i.e., casefolded) state:
|
Enabled = true
|
||||||
MaxTargetLength = 64
|
|
||||||
|
|
||||||
// latest schema of the db
|
// latest schema of the db
|
||||||
latestDbSchema = "2"
|
latestDbSchema = "2"
|
||||||
@ -63,10 +60,16 @@ type MySQL struct {
|
|||||||
trackAccountMessages atomic.Uint32
|
trackAccountMessages atomic.Uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mysql *MySQL) Initialize(logger *logger.Manager, config Config) {
|
var _ history.Database = (*MySQL)(nil)
|
||||||
|
|
||||||
|
func NewMySQLDatabase(logger *logger.Manager, config Config) (*MySQL, error) {
|
||||||
|
var mysql MySQL
|
||||||
|
|
||||||
mysql.logger = logger
|
mysql.logger = logger
|
||||||
mysql.wakeForgetter = make(chan e, 1)
|
mysql.wakeForgetter = make(chan e, 1)
|
||||||
mysql.SetConfig(config)
|
mysql.SetConfig(config)
|
||||||
|
|
||||||
|
return &mysql, mysql.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mysql *MySQL) SetConfig(config Config) {
|
func (mysql *MySQL) SetConfig(config Config) {
|
||||||
@ -88,7 +91,7 @@ func (mysql *MySQL) getExpireTime() (expireTime time.Duration) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQL) Open() (err error) {
|
func (m *MySQL) open() (err error) {
|
||||||
var address string
|
var address string
|
||||||
if m.config.SocketPath != "" {
|
if m.config.SocketPath != "" {
|
||||||
address = fmt.Sprintf("unix(%s)", m.config.SocketPath)
|
address = fmt.Sprintf("unix(%s)", m.config.SocketPath)
|
||||||
@ -127,7 +130,7 @@ func (m *MySQL) Open() (err error) {
|
|||||||
|
|
||||||
func (mysql *MySQL) fixSchemas() (err error) {
|
func (mysql *MySQL) fixSchemas() (err error) {
|
||||||
_, err = mysql.db.Exec(`CREATE TABLE IF NOT EXISTS metadata (
|
_, err = mysql.db.Exec(`CREATE TABLE IF NOT EXISTS metadata (
|
||||||
key_name VARCHAR(32) primary key,
|
key_name VARCHAR(32) PRIMARY KEY,
|
||||||
value VARCHAR(32) NOT NULL
|
value VARCHAR(32) NOT NULL
|
||||||
) CHARSET=ascii COLLATE=ascii_bin;`)
|
) CHARSET=ascii COLLATE=ascii_bin;`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -135,17 +138,17 @@ func (mysql *MySQL) fixSchemas() (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var schema string
|
var schema string
|
||||||
err = mysql.db.QueryRow(`select value from metadata where key_name = ?;`, keySchemaVersion).Scan(&schema)
|
err = mysql.db.QueryRow(`SELECT value FROM metadata WHERE key_name = ?;`, keySchemaVersion).Scan(&schema)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
err = mysql.createTables()
|
err = mysql.createTables()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaVersion, latestDbSchema)
|
_, err = mysql.db.Exec(`INSERT INTO metadata (key_name, value) VALUES (?, ?);`, keySchemaVersion, latestDbSchema)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
|
_, err = mysql.db.Exec(`INSERT INTO metadata (key_name, value) VALUES (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -158,7 +161,7 @@ func (mysql *MySQL) fixSchemas() (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var minorVersion string
|
var minorVersion string
|
||||||
err = mysql.db.QueryRow(`select value from metadata where key_name = ?;`, keySchemaMinorVersion).Scan(&minorVersion)
|
err = mysql.db.QueryRow(`SELECT value FROM metadata WHERE key_name = ?;`, keySchemaMinorVersion).Scan(&minorVersion)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
// XXX for now, the only minor version upgrade is the account tracking tables
|
// XXX for now, the only minor version upgrade is the account tracking tables
|
||||||
err = mysql.createComplianceTables()
|
err = mysql.createComplianceTables()
|
||||||
@ -169,7 +172,7 @@ func (mysql *MySQL) fixSchemas() (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err = mysql.db.Exec(`insert into metadata (key_name, value) values (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
|
_, err = mysql.db.Exec(`INSERT INTO metadata (key_name, value) VALUES (?, ?);`, keySchemaMinorVersion, latestDbMinorVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -179,13 +182,15 @@ func (mysql *MySQL) fixSchemas() (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err = mysql.db.Exec(`update metadata set value = ? where key_name = ?;`, latestDbMinorVersion, keySchemaMinorVersion)
|
_, err = mysql.db.Exec(`UPDATE metadata SET value = ? WHERE key_name = ?;`, latestDbMinorVersion, keySchemaMinorVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if err == nil && minorVersion != latestDbMinorVersion {
|
} else if err == nil && minorVersion != latestDbMinorVersion {
|
||||||
// TODO: if minorVersion < latestDbMinorVersion, upgrade,
|
// TODO: if minorVersion < latestDbMinorVersion, upgrade,
|
||||||
// if latestDbMinorVersion < minorVersion, ignore because backwards compatible
|
// if latestDbMinorVersion < minorVersion, ignore because backwards compatible
|
||||||
|
} else if err != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -415,7 +420,7 @@ func (mysql *MySQL) deleteCorrespondents(ctx context.Context, threshold int64) {
|
|||||||
} else {
|
} else {
|
||||||
count, err := result.RowsAffected()
|
count, err := result.RowsAffected()
|
||||||
if !mysql.logError("error deleting correspondents", err) {
|
if !mysql.logError("error deleting correspondents", err) {
|
||||||
mysql.logger.Debug(fmt.Sprintf("deleted %d correspondents entries", count))
|
mysql.logger.Debug("mysql", fmt.Sprintf("deleted %d correspondents entries", count))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -622,40 +627,46 @@ func (mysql *MySQL) AddChannelItem(target string, item history.Item, account str
|
|||||||
|
|
||||||
func (mysql *MySQL) insertSequenceEntry(ctx context.Context, target string, messageTime int64, id int64) (err error) {
|
func (mysql *MySQL) insertSequenceEntry(ctx context.Context, target string, messageTime int64, id int64) (err error) {
|
||||||
_, err = mysql.insertSequence.ExecContext(ctx, target, messageTime, id)
|
_, err = mysql.insertSequence.ExecContext(ctx, target, messageTime, id)
|
||||||
mysql.logError("could not insert sequence entry", err)
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not insert sequence entry: %w", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mysql *MySQL) insertConversationEntry(ctx context.Context, target, correspondent string, messageTime int64, id int64) (err error) {
|
func (mysql *MySQL) insertConversationEntry(ctx context.Context, target, correspondent string, messageTime int64, id int64) (err error) {
|
||||||
_, err = mysql.insertConversation.ExecContext(ctx, target, correspondent, messageTime, id)
|
_, err = mysql.insertConversation.ExecContext(ctx, target, correspondent, messageTime, id)
|
||||||
mysql.logError("could not insert conversations entry", err)
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not insert conversations entry: %w", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mysql *MySQL) insertCorrespondentsEntry(ctx context.Context, target, correspondent string, messageTime int64, historyId int64) (err error) {
|
func (mysql *MySQL) insertCorrespondentsEntry(ctx context.Context, target, correspondent string, messageTime int64, historyId int64) (err error) {
|
||||||
_, err = mysql.insertCorrespondent.ExecContext(ctx, target, correspondent, messageTime, messageTime)
|
_, err = mysql.insertCorrespondent.ExecContext(ctx, target, correspondent, messageTime, messageTime)
|
||||||
mysql.logError("could not insert conversations entry", err)
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not insert correspondents entry: %w", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mysql *MySQL) insertBase(ctx context.Context, item history.Item) (id int64, err error) {
|
func (mysql *MySQL) insertBase(ctx context.Context, item history.Item) (id int64, err error) {
|
||||||
value, err := marshalItem(&item)
|
value, err := history.MarshalItem(&item)
|
||||||
if mysql.logError("could not marshal item", err) {
|
if err != nil {
|
||||||
return
|
return 0, fmt.Errorf("could not marshal item: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgidBytes, err := decodeMsgid(item.Message.Msgid)
|
msgidBytes, err := utils.DecodeSecretToken(item.Message.Msgid)
|
||||||
if mysql.logError("could not decode msgid", err) {
|
if err != nil {
|
||||||
return
|
return 0, fmt.Errorf("could not decode msgid: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := mysql.insertHistory.ExecContext(ctx, value, msgidBytes)
|
result, err := mysql.insertHistory.ExecContext(ctx, value, msgidBytes)
|
||||||
if mysql.logError("could not insert item", err) {
|
if err != nil {
|
||||||
return
|
return 0, fmt.Errorf("could not insert item: %w", err)
|
||||||
}
|
}
|
||||||
id, err = result.LastInsertId()
|
id, err = result.LastInsertId()
|
||||||
if mysql.logError("could not insert item", err) {
|
if err != nil {
|
||||||
return
|
return 0, fmt.Errorf("could not insert item: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -666,7 +677,9 @@ func (mysql *MySQL) insertAccountMessageEntry(ctx context.Context, id int64, acc
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err = mysql.insertAccountMessage.ExecContext(ctx, id, account)
|
_, err = mysql.insertAccountMessage.ExecContext(ctx, id, account)
|
||||||
mysql.logError("could not insert account-message entry", err)
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not insert account-message entry: %w", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -734,20 +747,25 @@ func (mysql *MySQL) DeleteMsgid(msgid, accountName string) (err error) {
|
|||||||
|
|
||||||
_, id, data, err := mysql.lookupMsgid(ctx, msgid, true)
|
_, id, data, err := mysql.lookupMsgid(ctx, msgid, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return history.ErrNotFound
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if accountName != "*" {
|
if accountName != "*" {
|
||||||
var item history.Item
|
var item history.Item
|
||||||
err = unmarshalItem(data, &item)
|
err = history.UnmarshalItem(data, &item)
|
||||||
// delete if the entry is corrupt
|
// delete if the entry is corrupt
|
||||||
if err == nil && item.AccountName != accountName {
|
if err == nil && item.AccountName != accountName {
|
||||||
return ErrDisallowed
|
return history.ErrDisallowed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = mysql.deleteHistoryIDs(ctx, []uint64{id})
|
err = mysql.deleteHistoryIDs(ctx, []uint64{id})
|
||||||
mysql.logError("couldn't delete msgid", err)
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't delete msgid: %w", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -783,7 +801,7 @@ func (mysql *MySQL) Export(account string, writer io.Writer) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = unmarshalItem(blob, &item)
|
err = history.UnmarshalItem(blob, &item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -811,8 +829,11 @@ func (mysql *MySQL) Export(account string, writer io.Writer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData bool) (result time.Time, id uint64, data []byte, err error) {
|
func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData bool) (result time.Time, id uint64, data []byte, err error) {
|
||||||
decoded, err := decodeMsgid(msgid)
|
decoded, err := utils.DecodeSecretToken(msgid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// use sql.ErrNoRows internally for consistency, translate to history.ErrNotFound
|
||||||
|
// at the package boundary if necessary
|
||||||
|
err = sql.ErrNoRows
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cols := `sequence.nanotime, conversations.nanotime`
|
cols := `sequence.nanotime, conversations.nanotime`
|
||||||
@ -830,10 +851,10 @@ func (mysql *MySQL) lookupMsgid(ctx context.Context, msgid string, includeData b
|
|||||||
} else {
|
} else {
|
||||||
err = row.Scan(&nanoSeq, &nanoConv, &id, &data)
|
err = row.Scan(&nanoSeq, &nanoConv, &id, &data)
|
||||||
}
|
}
|
||||||
if err != sql.ErrNoRows {
|
|
||||||
mysql.logError("could not resolve msgid to time", err)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
err = fmt.Errorf("could not resolve msgid to time: %w", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
nanotime := extractNanotime(nanoSeq, nanoConv)
|
nanotime := extractNanotime(nanoSeq, nanoConv)
|
||||||
@ -856,8 +877,8 @@ func extractNanotime(seq, conv sql.NullInt64) (result int64) {
|
|||||||
|
|
||||||
func (mysql *MySQL) selectItems(ctx context.Context, query string, args ...interface{}) (results []history.Item, err error) {
|
func (mysql *MySQL) selectItems(ctx context.Context, query string, args ...interface{}) (results []history.Item, err error) {
|
||||||
rows, err := mysql.db.QueryContext(ctx, query, args...)
|
rows, err := mysql.db.QueryContext(ctx, query, args...)
|
||||||
if mysql.logError("could not select history items", err) {
|
if err != nil {
|
||||||
return
|
return nil, fmt.Errorf("could not select history items: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@ -866,12 +887,12 @@ func (mysql *MySQL) selectItems(ctx context.Context, query string, args ...inter
|
|||||||
var blob []byte
|
var blob []byte
|
||||||
var item history.Item
|
var item history.Item
|
||||||
err = rows.Scan(&blob)
|
err = rows.Scan(&blob)
|
||||||
if mysql.logError("could not scan history item", err) {
|
if err != nil {
|
||||||
return
|
return nil, fmt.Errorf("could not scan history item: %w", err)
|
||||||
}
|
}
|
||||||
err = unmarshalItem(blob, &item)
|
err = history.UnmarshalItem(blob, &item)
|
||||||
if mysql.logError("could not unmarshal history item", err) {
|
if err != nil {
|
||||||
return
|
return nil, fmt.Errorf("could not unmarshal history item: %w", err)
|
||||||
}
|
}
|
||||||
results = append(results, item)
|
results = append(results, item)
|
||||||
}
|
}
|
||||||
@ -917,7 +938,7 @@ func (mysql *MySQL) betweenTimestamps(ctx context.Context, target, correspondent
|
|||||||
|
|
||||||
results, err = mysql.selectItems(ctx, queryBuf.String(), args...)
|
results, err = mysql.selectItems(ctx, queryBuf.String(), args...)
|
||||||
if err == nil && !ascending {
|
if err == nil && !ascending {
|
||||||
utils.ReverseSlice(results)
|
slices.Reverse(results)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -948,7 +969,7 @@ func (mysql *MySQL) listCorrespondentsInternal(ctx context.Context, target strin
|
|||||||
|
|
||||||
rows, err := mysql.db.QueryContext(ctx, query, args...)
|
rows, err := mysql.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return nil, fmt.Errorf("could not query correspondents: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var correspondent string
|
var correspondent string
|
||||||
@ -956,21 +977,34 @@ func (mysql *MySQL) listCorrespondentsInternal(ctx context.Context, target strin
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
err = rows.Scan(&correspondent, &nanotime)
|
err = rows.Scan(&correspondent, &nanotime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return nil, fmt.Errorf("could not scan correspondents: %w", err)
|
||||||
}
|
}
|
||||||
results = append(results, history.TargetListing{
|
results = append(results, history.TargetListing{
|
||||||
CfName: correspondent,
|
CfName: correspondent,
|
||||||
Time: time.Unix(0, nanotime),
|
Time: time.Unix(0, nanotime).UTC(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ascending {
|
if !ascending {
|
||||||
utils.ReverseSlice(results)
|
slices.Reverse(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mysql *MySQL) ListCorrespondents(cftarget string, start, end time.Time, limit int) (results []history.TargetListing, err error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), mysql.getTimeout())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// TODO accept msgids here?
|
||||||
|
|
||||||
|
results, err = mysql.listCorrespondentsInternal(ctx, cftarget, start, end, time.Time{}, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not read correspondents: %w", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetListing, err error) {
|
func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetListing, err error) {
|
||||||
if mysql.db == nil {
|
if mysql.db == nil {
|
||||||
return
|
return
|
||||||
@ -984,7 +1018,7 @@ func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetL
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var queryBuf strings.Builder
|
var queryBuf strings.Builder
|
||||||
args := make([]interface{}, 0, len(results))
|
args := make([]interface{}, 0, len(cfchannels))
|
||||||
// https://dev.mysql.com/doc/refman/8.0/en/group-by-optimization.html
|
// https://dev.mysql.com/doc/refman/8.0/en/group-by-optimization.html
|
||||||
// this should be a "loose index scan"
|
// this should be a "loose index scan"
|
||||||
queryBuf.WriteString(`SELECT sequence.target, MAX(sequence.nanotime) FROM sequence
|
queryBuf.WriteString(`SELECT sequence.target, MAX(sequence.nanotime) FROM sequence
|
||||||
@ -999,8 +1033,8 @@ func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetL
|
|||||||
queryBuf.WriteString(") GROUP BY sequence.target;")
|
queryBuf.WriteString(") GROUP BY sequence.target;")
|
||||||
|
|
||||||
rows, err := mysql.db.QueryContext(ctx, queryBuf.String(), args...)
|
rows, err := mysql.db.QueryContext(ctx, queryBuf.String(), args...)
|
||||||
if mysql.logError("could not query channel listings", err) {
|
if err != nil {
|
||||||
return
|
return nil, fmt.Errorf("could not query channel listings: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
@ -1008,23 +1042,24 @@ func (mysql *MySQL) ListChannels(cfchannels []string) (results []history.TargetL
|
|||||||
var nanotime int64
|
var nanotime int64
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
err = rows.Scan(&target, &nanotime)
|
err = rows.Scan(&target, &nanotime)
|
||||||
if mysql.logError("could not scan channel listings", err) {
|
if err != nil {
|
||||||
return
|
return nil, fmt.Errorf("could not scan channel listings: %w", err)
|
||||||
}
|
}
|
||||||
results = append(results, history.TargetListing{
|
results = append(results, history.TargetListing{
|
||||||
CfName: target,
|
CfName: target,
|
||||||
Time: time.Unix(0, nanotime),
|
Time: time.Unix(0, nanotime).UTC(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mysql *MySQL) Close() {
|
func (mysql *MySQL) Close() (err error) {
|
||||||
// closing the database will close our prepared statements as well
|
// closing the database will close our prepared statements as well
|
||||||
if mysql.db != nil {
|
if mysql.db != nil {
|
||||||
mysql.db.Close()
|
err = mysql.db.Close()
|
||||||
}
|
}
|
||||||
mysql.db = nil
|
mysql.db = nil
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// implements history.Sequence, emulating a single history buffer (for a channel,
|
// implements history.Sequence, emulating a single history buffer (for a channel,
|
||||||
@ -1071,19 +1106,6 @@ func (s *mySQLHistorySequence) Around(start history.Selector, limit int) (result
|
|||||||
return history.GenericAround(s, start, limit)
|
return history.GenericAround(s, start, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (seq *mySQLHistorySequence) ListCorrespondents(start, end history.Selector, limit int) (results []history.TargetListing, err error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), seq.mysql.getTimeout())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// TODO accept msgids here?
|
|
||||||
startTime := start.Time
|
|
||||||
endTime := end.Time
|
|
||||||
|
|
||||||
results, err = seq.mysql.listCorrespondentsInternal(ctx, seq.target, startTime, endTime, seq.cutoff, limit)
|
|
||||||
seq.mysql.logError("could not read correspondents", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (seq *mySQLHistorySequence) Cutoff() time.Time {
|
func (seq *mySQLHistorySequence) Cutoff() time.Time {
|
||||||
return seq.cutoff
|
return seq.cutoff
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
package mysql
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/ergochat/ergo/irc/history"
|
|
||||||
"github.com/ergochat/ergo/irc/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 123 / '{' is the magic number that means JSON;
|
|
||||||
// if we want to do a binary encoding later, we just have to add different magic version numbers
|
|
||||||
|
|
||||||
func marshalItem(item *history.Item) (result []byte, err error) {
|
|
||||||
return json.Marshal(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func unmarshalItem(data []byte, result *history.Item) (err error) {
|
|
||||||
return json.Unmarshal(data, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeMsgid(msgid string) ([]byte, error) {
|
|
||||||
return utils.B32Encoder.DecodeString(msgid)
|
|
||||||
}
|
|
||||||
31
irc/mysql/stub.go
Normal file
31
irc/mysql/stub.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
//go:build !mysql
|
||||||
|
|
||||||
|
package mysql
|
||||||
|
|
||||||
|
// Copyright (c) 2020 Shivaram Lingamneni
|
||||||
|
// released under the MIT license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/history"
|
||||||
|
"github.com/ergochat/ergo/irc/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enabled is false when MySQL support is not compiled in
|
||||||
|
const Enabled = false
|
||||||
|
|
||||||
|
// MySQL is a stub implementation when the mysql build tag is not present
|
||||||
|
type MySQL struct {
|
||||||
|
history.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMySQLDatabase returns an error when MySQL support is not compiled in
|
||||||
|
func NewMySQLDatabase(logger *logger.Manager, config Config) (*MySQL, error) {
|
||||||
|
return nil, errors.New("MySQL support not enabled in this build. Rebuild with `make build_full` to enable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfig is a no-op for the stub implementation
|
||||||
|
func (m *MySQL) SetConfig(config Config) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
@ -34,7 +34,7 @@ func performNickChange(server *Server, client *Client, target *Client, session *
|
|||||||
origNickMask := details.nickMask
|
origNickMask := details.nickMask
|
||||||
isSanick := client != target
|
isSanick := client != target
|
||||||
|
|
||||||
assignedNickname, err, back := client.server.clients.SetNick(target, session, nickname, false)
|
assignedNickname, err, awayChanged := client.server.clients.SetNick(target, session, nickname, false)
|
||||||
if err == errNicknameInUse {
|
if err == errNicknameInUse {
|
||||||
if !isSanick {
|
if !isSanick {
|
||||||
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, details.nick, utils.SafeErrorParam(nickname), client.t("Nickname is already in use"))
|
rb.Add(nil, server.name, ERR_NICKNAMEINUSE, details.nick, utils.SafeErrorParam(nickname), client.t("Nickname is already in use"))
|
||||||
@ -43,6 +43,8 @@ func performNickChange(server *Server, client *Client, target *Client, session *
|
|||||||
}
|
}
|
||||||
} else if err == errNicknameReserved {
|
} 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"))
|
||||||
}
|
}
|
||||||
@ -115,18 +117,22 @@ func performNickChange(server *Server, client *Client, target *Client, session *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if back {
|
if awayChanged {
|
||||||
dispatchAwayNotify(session.client, false, "")
|
dispatchAwayNotify(session.client, session.client.AwayMessage())
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, channel := range target.Channels() {
|
for _, channel := range target.Channels() {
|
||||||
channel.AddHistoryItem(histItem, details.account)
|
if channel.memberIsVisible(client) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -174,7 +174,7 @@ an administrator can set use this command to set up user accounts.`,
|
|||||||
help: `Syntax: $bSAVERIFY <username>$b
|
help: `Syntax: $bSAVERIFY <username>$b
|
||||||
|
|
||||||
SAVERIFY manually verifies an account that is pending verification.`,
|
SAVERIFY manually verifies an account that is pending verification.`,
|
||||||
helpShort: `$bSAREGISTER$b registers an account on someone else's behalf.`,
|
helpShort: `$bSAVERIFY$b manually verifies an account that is pending verification.`,
|
||||||
enabled: servCmdRequiresAuthEnabled, // deliberate
|
enabled: servCmdRequiresAuthEnabled, // deliberate
|
||||||
capabs: []string{"accreg"},
|
capabs: []string{"accreg"},
|
||||||
minParams: 1,
|
minParams: 1,
|
||||||
@ -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
|
||||||
@ -811,7 +823,7 @@ func nsGroupHandler(service *ircService, server *Server, client *Client, command
|
|||||||
func nsLoginThrottleCheck(service *ircService, client *Client, rb *ResponseBuffer) (success bool) {
|
func nsLoginThrottleCheck(service *ircService, client *Client, rb *ResponseBuffer) (success bool) {
|
||||||
throttled, remainingTime := client.checkLoginThrottle()
|
throttled, remainingTime := client.checkLoginThrottle()
|
||||||
if throttled {
|
if throttled {
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime))
|
service.Notice(rb, fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime.Round(time.Millisecond)))
|
||||||
}
|
}
|
||||||
return !throttled
|
return !throttled
|
||||||
}
|
}
|
||||||
@ -954,9 +966,9 @@ func nsInfoHandler(service *ircService, server *Server, client *Client, command
|
|||||||
|
|
||||||
func listRegisteredChannels(service *ircService, accountName string, rb *ResponseBuffer) {
|
func listRegisteredChannels(service *ircService, accountName string, rb *ResponseBuffer) {
|
||||||
client := rb.session.client
|
client := rb.session.client
|
||||||
channels := client.server.accounts.ChannelsForAccount(accountName)
|
channels := client.server.channels.ChannelsForAccount(accountName)
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Account %s has %d registered channel(s)."), accountName, len(channels)))
|
service.Notice(rb, fmt.Sprintf(client.t("Account %s has %d registered channel(s)."), accountName, len(channels)))
|
||||||
for _, channel := range rb.session.client.server.accounts.ChannelsForAccount(accountName) {
|
for _, channel := range channels {
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Registered channel: %s"), channel))
|
service.Notice(rb, fmt.Sprintf(client.t("Registered channel: %s"), channel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1043,10 +1055,10 @@ func nsSaregisterHandler(service *ircService, server *Server, client *Client, co
|
|||||||
var failCode string
|
var failCode string
|
||||||
if err == errAccountAlreadyRegistered || err == errAccountAlreadyVerified {
|
if err == errAccountAlreadyRegistered || err == errAccountAlreadyVerified {
|
||||||
errMsg = client.t("Account already exists")
|
errMsg = client.t("Account already exists")
|
||||||
failCode = "USERNAME_EXISTS"
|
failCode = "ACCOUNT_EXISTS"
|
||||||
} else if err == errNameReserved {
|
} else if err == errNameReserved {
|
||||||
errMsg = client.t(err.Error())
|
errMsg = client.t(err.Error())
|
||||||
failCode = "USERNAME_EXISTS"
|
failCode = "ACCOUNT_EXISTS"
|
||||||
} else if err == errAccountBadPassphrase {
|
} else if err == errAccountBadPassphrase {
|
||||||
errMsg = client.t("Passphrase contains forbidden characters or is otherwise invalid")
|
errMsg = client.t("Passphrase contains forbidden characters or is otherwise invalid")
|
||||||
failCode = "INVALID_PASSWORD"
|
failCode = "INVALID_PASSWORD"
|
||||||
@ -1310,21 +1322,24 @@ func nsClientsListHandler(service *ircService, server *Server, client *Client, p
|
|||||||
service.Notice(rb, fmt.Sprintf(client.t("Client %d:"), session.sessionID))
|
service.Notice(rb, fmt.Sprintf(client.t("Client %d:"), session.sessionID))
|
||||||
}
|
}
|
||||||
if session.deviceID != "" {
|
if session.deviceID != "" {
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Device ID: %s"), session.deviceID))
|
service.Notice(rb, fmt.Sprintf(client.t("Device ID: %s"), session.deviceID))
|
||||||
}
|
}
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("IP address: %s"), session.ip.String()))
|
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Hostname: %s"), session.hostname))
|
|
||||||
if hasPrivs {
|
if hasPrivs {
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Connection: %s"), session.connInfo))
|
service.Notice(rb, fmt.Sprintf(client.t("Debug log ID: %s"), session.connID))
|
||||||
}
|
}
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Created at: %s"), session.ctime.Format(time.RFC1123)))
|
service.Notice(rb, fmt.Sprintf(client.t("IP address: %s"), session.ip.String()))
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Last active: %s"), session.atime.Format(time.RFC1123)))
|
service.Notice(rb, fmt.Sprintf(client.t("Hostname: %s"), session.hostname))
|
||||||
|
if hasPrivs {
|
||||||
|
service.Notice(rb, fmt.Sprintf(client.t("Connection: %s"), session.connInfo))
|
||||||
|
}
|
||||||
|
service.Notice(rb, fmt.Sprintf(client.t("Created at: %s"), session.ctime.Format(time.RFC1123)))
|
||||||
|
service.Notice(rb, fmt.Sprintf(client.t("Last active: %s"), session.atime.Format(time.RFC1123)))
|
||||||
if session.certfp != "" {
|
if session.certfp != "" {
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("Certfp: %s"), session.certfp))
|
service.Notice(rb, fmt.Sprintf(client.t("Certfp: %s"), session.certfp))
|
||||||
}
|
}
|
||||||
for _, capStr := range session.caps {
|
for _, capStr := range session.caps {
|
||||||
if capStr != "" {
|
if capStr != "" {
|
||||||
service.Notice(rb, fmt.Sprintf(client.t("IRCv3 CAPs: %s"), capStr))
|
service.Notice(rb, fmt.Sprintf(client.t("IRCv3 CAPs: %s"), capStr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
374
irc/numerics.go
374
irc/numerics.go
@ -12,189 +12,193 @@ package irc
|
|||||||
// server ecosystem out there. Custom numerics will be marked as such.
|
// server ecosystem out there. Custom numerics will be marked as such.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RPL_WELCOME = "001"
|
RPL_WELCOME = "001"
|
||||||
RPL_YOURHOST = "002"
|
RPL_YOURHOST = "002"
|
||||||
RPL_CREATED = "003"
|
RPL_CREATED = "003"
|
||||||
RPL_MYINFO = "004"
|
RPL_MYINFO = "004"
|
||||||
RPL_ISUPPORT = "005"
|
RPL_ISUPPORT = "005"
|
||||||
RPL_SNOMASKIS = "008"
|
RPL_SNOMASKIS = "008"
|
||||||
RPL_BOUNCE = "010"
|
RPL_BOUNCE = "010"
|
||||||
RPL_TRACELINK = "200"
|
RPL_TRACELINK = "200"
|
||||||
RPL_TRACECONNECTING = "201"
|
RPL_TRACECONNECTING = "201"
|
||||||
RPL_TRACEHANDSHAKE = "202"
|
RPL_TRACEHANDSHAKE = "202"
|
||||||
RPL_TRACEUNKNOWN = "203"
|
RPL_TRACEUNKNOWN = "203"
|
||||||
RPL_TRACEOPERATOR = "204"
|
RPL_TRACEOPERATOR = "204"
|
||||||
RPL_TRACEUSER = "205"
|
RPL_TRACEUSER = "205"
|
||||||
RPL_TRACESERVER = "206"
|
RPL_TRACESERVER = "206"
|
||||||
RPL_TRACESERVICE = "207"
|
RPL_TRACESERVICE = "207"
|
||||||
RPL_TRACENEWTYPE = "208"
|
RPL_TRACENEWTYPE = "208"
|
||||||
RPL_TRACECLASS = "209"
|
RPL_TRACECLASS = "209"
|
||||||
RPL_TRACERECONNECT = "210"
|
RPL_TRACERECONNECT = "210"
|
||||||
RPL_STATSLINKINFO = "211"
|
RPL_STATSLINKINFO = "211"
|
||||||
RPL_STATSCOMMANDS = "212"
|
RPL_STATSCOMMANDS = "212"
|
||||||
RPL_ENDOFSTATS = "219"
|
RPL_ENDOFSTATS = "219"
|
||||||
RPL_UMODEIS = "221"
|
RPL_UMODEIS = "221"
|
||||||
RPL_SERVLIST = "234"
|
RPL_SERVLIST = "234"
|
||||||
RPL_SERVLISTEND = "235"
|
RPL_SERVLISTEND = "235"
|
||||||
RPL_STATSUPTIME = "242"
|
RPL_STATSUPTIME = "242"
|
||||||
RPL_STATSOLINE = "243"
|
RPL_STATSOLINE = "243"
|
||||||
RPL_LUSERCLIENT = "251"
|
RPL_LUSERCLIENT = "251"
|
||||||
RPL_LUSEROP = "252"
|
RPL_LUSEROP = "252"
|
||||||
RPL_LUSERUNKNOWN = "253"
|
RPL_LUSERUNKNOWN = "253"
|
||||||
RPL_LUSERCHANNELS = "254"
|
RPL_LUSERCHANNELS = "254"
|
||||||
RPL_LUSERME = "255"
|
RPL_LUSERME = "255"
|
||||||
RPL_ADMINME = "256"
|
RPL_ADMINME = "256"
|
||||||
RPL_ADMINLOC1 = "257"
|
RPL_ADMINLOC1 = "257"
|
||||||
RPL_ADMINLOC2 = "258"
|
RPL_ADMINLOC2 = "258"
|
||||||
RPL_ADMINEMAIL = "259"
|
RPL_ADMINEMAIL = "259"
|
||||||
RPL_TRACELOG = "261"
|
RPL_TRACELOG = "261"
|
||||||
RPL_TRACEEND = "262"
|
RPL_TRACEEND = "262"
|
||||||
RPL_TRYAGAIN = "263"
|
RPL_TRYAGAIN = "263"
|
||||||
RPL_LOCALUSERS = "265"
|
RPL_LOCALUSERS = "265"
|
||||||
RPL_GLOBALUSERS = "266"
|
RPL_GLOBALUSERS = "266"
|
||||||
RPL_WHOISCERTFP = "276"
|
RPL_WHOISCERTFP = "276"
|
||||||
RPL_AWAY = "301"
|
RPL_AWAY = "301"
|
||||||
RPL_USERHOST = "302"
|
RPL_USERHOST = "302"
|
||||||
RPL_ISON = "303"
|
RPL_ISON = "303"
|
||||||
RPL_UNAWAY = "305"
|
RPL_UNAWAY = "305"
|
||||||
RPL_NOWAWAY = "306"
|
RPL_NOWAWAY = "306"
|
||||||
RPL_WHOISUSER = "311"
|
RPL_WHOISUSER = "311"
|
||||||
RPL_WHOISSERVER = "312"
|
RPL_WHOISSERVER = "312"
|
||||||
RPL_WHOISOPERATOR = "313"
|
RPL_WHOISOPERATOR = "313"
|
||||||
RPL_WHOWASUSER = "314"
|
RPL_WHOWASUSER = "314"
|
||||||
RPL_ENDOFWHO = "315"
|
RPL_ENDOFWHO = "315"
|
||||||
RPL_WHOISIDLE = "317"
|
RPL_WHOISIDLE = "317"
|
||||||
RPL_ENDOFWHOIS = "318"
|
RPL_ENDOFWHOIS = "318"
|
||||||
RPL_WHOISCHANNELS = "319"
|
RPL_WHOISCHANNELS = "319"
|
||||||
RPL_LIST = "322"
|
RPL_LIST = "322"
|
||||||
RPL_LISTEND = "323"
|
RPL_LISTEND = "323"
|
||||||
RPL_CHANNELMODEIS = "324"
|
RPL_CHANNELMODEIS = "324"
|
||||||
RPL_UNIQOPIS = "325"
|
RPL_UNIQOPIS = "325"
|
||||||
RPL_CREATIONTIME = "329"
|
RPL_CREATIONTIME = "329"
|
||||||
RPL_WHOISACCOUNT = "330"
|
RPL_WHOISACCOUNT = "330"
|
||||||
RPL_NOTOPIC = "331"
|
RPL_NOTOPIC = "331"
|
||||||
RPL_TOPIC = "332"
|
RPL_TOPIC = "332"
|
||||||
RPL_TOPICTIME = "333"
|
RPL_TOPICTIME = "333"
|
||||||
RPL_WHOISBOT = "335"
|
RPL_WHOISBOT = "335"
|
||||||
RPL_WHOISACTUALLY = "338"
|
RPL_WHOISACTUALLY = "338"
|
||||||
RPL_INVITING = "341"
|
RPL_INVITING = "341"
|
||||||
RPL_SUMMONING = "342"
|
RPL_SUMMONING = "342"
|
||||||
RPL_INVITELIST = "346"
|
RPL_INVEXLIST = "346"
|
||||||
RPL_ENDOFINVITELIST = "347"
|
RPL_ENDOFINVEXLIST = "347"
|
||||||
RPL_EXCEPTLIST = "348"
|
RPL_EXCEPTLIST = "348"
|
||||||
RPL_ENDOFEXCEPTLIST = "349"
|
RPL_ENDOFEXCEPTLIST = "349"
|
||||||
RPL_VERSION = "351"
|
RPL_VERSION = "351"
|
||||||
RPL_WHOREPLY = "352"
|
RPL_WHOREPLY = "352"
|
||||||
RPL_NAMREPLY = "353"
|
RPL_NAMREPLY = "353"
|
||||||
RPL_WHOSPCRPL = "354"
|
RPL_WHOSPCRPL = "354"
|
||||||
RPL_LINKS = "364"
|
RPL_LINKS = "364"
|
||||||
RPL_ENDOFLINKS = "365"
|
RPL_ENDOFLINKS = "365"
|
||||||
RPL_ENDOFNAMES = "366"
|
RPL_ENDOFNAMES = "366"
|
||||||
RPL_BANLIST = "367"
|
RPL_BANLIST = "367"
|
||||||
RPL_ENDOFBANLIST = "368"
|
RPL_ENDOFBANLIST = "368"
|
||||||
RPL_ENDOFWHOWAS = "369"
|
RPL_ENDOFWHOWAS = "369"
|
||||||
RPL_INFO = "371"
|
RPL_INFO = "371"
|
||||||
RPL_MOTD = "372"
|
RPL_MOTD = "372"
|
||||||
RPL_ENDOFINFO = "374"
|
RPL_ENDOFINFO = "374"
|
||||||
RPL_MOTDSTART = "375"
|
RPL_MOTDSTART = "375"
|
||||||
RPL_ENDOFMOTD = "376"
|
RPL_ENDOFMOTD = "376"
|
||||||
RPL_WHOISMODES = "379"
|
RPL_WHOISMODES = "379"
|
||||||
RPL_YOUREOPER = "381"
|
RPL_YOUREOPER = "381"
|
||||||
RPL_REHASHING = "382"
|
RPL_REHASHING = "382"
|
||||||
RPL_YOURESERVICE = "383"
|
RPL_YOURESERVICE = "383"
|
||||||
RPL_TIME = "391"
|
RPL_TIME = "391"
|
||||||
RPL_USERSSTART = "392"
|
RPL_USERSSTART = "392"
|
||||||
RPL_USERS = "393"
|
RPL_USERS = "393"
|
||||||
RPL_ENDOFUSERS = "394"
|
RPL_ENDOFUSERS = "394"
|
||||||
RPL_NOUSERS = "395"
|
RPL_NOUSERS = "395"
|
||||||
ERR_UNKNOWNERROR = "400"
|
ERR_UNKNOWNERROR = "400"
|
||||||
ERR_NOSUCHNICK = "401"
|
ERR_NOSUCHNICK = "401"
|
||||||
ERR_NOSUCHSERVER = "402"
|
ERR_NOSUCHSERVER = "402"
|
||||||
ERR_NOSUCHCHANNEL = "403"
|
ERR_NOSUCHCHANNEL = "403"
|
||||||
ERR_CANNOTSENDTOCHAN = "404"
|
ERR_CANNOTSENDTOCHAN = "404"
|
||||||
ERR_TOOMANYCHANNELS = "405"
|
ERR_TOOMANYCHANNELS = "405"
|
||||||
ERR_WASNOSUCHNICK = "406"
|
ERR_WASNOSUCHNICK = "406"
|
||||||
ERR_TOOMANYTARGETS = "407"
|
ERR_TOOMANYTARGETS = "407"
|
||||||
ERR_NOSUCHSERVICE = "408"
|
ERR_NOSUCHSERVICE = "408"
|
||||||
ERR_NOORIGIN = "409"
|
ERR_NOORIGIN = "409"
|
||||||
ERR_INVALIDCAPCMD = "410"
|
ERR_INVALIDCAPCMD = "410"
|
||||||
ERR_NORECIPIENT = "411"
|
ERR_NORECIPIENT = "411"
|
||||||
ERR_NOTEXTTOSEND = "412"
|
ERR_NOTEXTTOSEND = "412"
|
||||||
ERR_NOTOPLEVEL = "413"
|
ERR_NOTOPLEVEL = "413"
|
||||||
ERR_WILDTOPLEVEL = "414"
|
ERR_WILDTOPLEVEL = "414"
|
||||||
ERR_BADMASK = "415"
|
ERR_BADMASK = "415"
|
||||||
ERR_INPUTTOOLONG = "417"
|
ERR_INPUTTOOLONG = "417"
|
||||||
ERR_UNKNOWNCOMMAND = "421"
|
ERR_UNKNOWNCOMMAND = "421"
|
||||||
ERR_NOMOTD = "422"
|
ERR_NOMOTD = "422"
|
||||||
ERR_NOADMININFO = "423"
|
ERR_NOADMININFO = "423"
|
||||||
ERR_FILEERROR = "424"
|
ERR_FILEERROR = "424"
|
||||||
ERR_NONICKNAMEGIVEN = "431"
|
ERR_NONICKNAMEGIVEN = "431"
|
||||||
ERR_ERRONEUSNICKNAME = "432"
|
ERR_ERRONEUSNICKNAME = "432"
|
||||||
ERR_NICKNAMEINUSE = "433"
|
ERR_NICKNAMEINUSE = "433"
|
||||||
ERR_NICKCOLLISION = "436"
|
ERR_NICKCOLLISION = "436"
|
||||||
ERR_UNAVAILRESOURCE = "437"
|
ERR_UNAVAILRESOURCE = "437"
|
||||||
ERR_REG_UNAVAILABLE = "440"
|
ERR_REG_UNAVAILABLE = "440"
|
||||||
ERR_USERNOTINCHANNEL = "441"
|
ERR_USERNOTINCHANNEL = "441"
|
||||||
ERR_NOTONCHANNEL = "442"
|
ERR_NOTONCHANNEL = "442"
|
||||||
ERR_USERONCHANNEL = "443"
|
ERR_USERONCHANNEL = "443"
|
||||||
ERR_NOLOGIN = "444"
|
ERR_NOLOGIN = "444"
|
||||||
ERR_SUMMONDISABLED = "445"
|
ERR_SUMMONDISABLED = "445"
|
||||||
ERR_USERSDISABLED = "446"
|
ERR_USERSDISABLED = "446"
|
||||||
ERR_NOTREGISTERED = "451"
|
ERR_NOTREGISTERED = "451"
|
||||||
ERR_NEEDMOREPARAMS = "461"
|
ERR_NEEDMOREPARAMS = "461"
|
||||||
ERR_ALREADYREGISTRED = "462"
|
ERR_ALREADYREGISTRED = "462"
|
||||||
ERR_NOPERMFORHOST = "463"
|
ERR_NOPERMFORHOST = "463"
|
||||||
ERR_PASSWDMISMATCH = "464"
|
ERR_PASSWDMISMATCH = "464"
|
||||||
ERR_YOUREBANNEDCREEP = "465"
|
ERR_YOUREBANNEDCREEP = "465"
|
||||||
ERR_YOUWILLBEBANNED = "466"
|
ERR_YOUWILLBEBANNED = "466"
|
||||||
ERR_KEYSET = "467"
|
ERR_KEYSET = "467"
|
||||||
ERR_INVALIDUSERNAME = "468"
|
ERR_INVALIDUSERNAME = "468"
|
||||||
ERR_LINKCHANNEL = "470"
|
ERR_LINKCHANNEL = "470"
|
||||||
ERR_CHANNELISFULL = "471"
|
ERR_CHANNELISFULL = "471"
|
||||||
ERR_UNKNOWNMODE = "472"
|
ERR_UNKNOWNMODE = "472"
|
||||||
ERR_INVITEONLYCHAN = "473"
|
ERR_INVITEONLYCHAN = "473"
|
||||||
ERR_BANNEDFROMCHAN = "474"
|
ERR_BANNEDFROMCHAN = "474"
|
||||||
ERR_BADCHANNELKEY = "475"
|
ERR_BADCHANNELKEY = "475"
|
||||||
ERR_BADCHANMASK = "476"
|
ERR_BADCHANMASK = "476"
|
||||||
ERR_NEEDREGGEDNICK = "477" // conflicted with ERR_NOCHANMODES; see #936
|
ERR_NEEDREGGEDNICK = "477" // conflicted with ERR_NOCHANMODES; see #936
|
||||||
ERR_BANLISTFULL = "478"
|
ERR_BANLISTFULL = "478"
|
||||||
ERR_NOPRIVILEGES = "481"
|
ERR_NOPRIVILEGES = "481"
|
||||||
ERR_CHANOPRIVSNEEDED = "482"
|
ERR_CHANOPRIVSNEEDED = "482"
|
||||||
ERR_CANTKILLSERVER = "483"
|
ERR_CANTKILLSERVER = "483"
|
||||||
ERR_RESTRICTED = "484"
|
ERR_RESTRICTED = "484"
|
||||||
ERR_UNIQOPPRIVSNEEDED = "485"
|
ERR_UNIQOPPRIVSNEEDED = "485"
|
||||||
ERR_NOOPERHOST = "491"
|
ERR_NOOPERHOST = "491"
|
||||||
ERR_UMODEUNKNOWNFLAG = "501"
|
ERR_UMODEUNKNOWNFLAG = "501"
|
||||||
ERR_USERSDONTMATCH = "502"
|
ERR_USERSDONTMATCH = "502"
|
||||||
ERR_HELPNOTFOUND = "524"
|
ERR_HELPNOTFOUND = "524"
|
||||||
ERR_CANNOTSENDRP = "573"
|
ERR_CANNOTSENDRP = "573"
|
||||||
RPL_WHOWASIP = "652"
|
RPL_WHOWASIP = "652"
|
||||||
RPL_WHOISSECURE = "671"
|
RPL_WHOISSECURE = "671"
|
||||||
RPL_YOURLANGUAGESARE = "687"
|
RPL_YOURLANGUAGESARE = "687"
|
||||||
ERR_INVALIDMODEPARAM = "696"
|
ERR_INVALIDMODEPARAM = "696"
|
||||||
ERR_LISTMODEALREADYSET = "697"
|
ERR_LISTMODEALREADYSET = "697"
|
||||||
ERR_LISTMODENOTSET = "698"
|
ERR_LISTMODENOTSET = "698"
|
||||||
RPL_HELPSTART = "704"
|
RPL_HELPSTART = "704"
|
||||||
RPL_HELPTXT = "705"
|
RPL_HELPTXT = "705"
|
||||||
RPL_ENDOFHELP = "706"
|
RPL_ENDOFHELP = "706"
|
||||||
ERR_NOPRIVS = "723"
|
ERR_NOPRIVS = "723"
|
||||||
RPL_MONONLINE = "730"
|
RPL_MONONLINE = "730"
|
||||||
RPL_MONOFFLINE = "731"
|
RPL_MONOFFLINE = "731"
|
||||||
RPL_MONLIST = "732"
|
RPL_MONLIST = "732"
|
||||||
RPL_ENDOFMONLIST = "733"
|
RPL_ENDOFMONLIST = "733"
|
||||||
ERR_MONLISTFULL = "734"
|
ERR_MONLISTFULL = "734"
|
||||||
RPL_LOGGEDIN = "900"
|
RPL_WHOISKEYVALUE = "760" // metadata numerics
|
||||||
RPL_LOGGEDOUT = "901"
|
RPL_KEYVALUE = "761"
|
||||||
ERR_NICKLOCKED = "902"
|
RPL_KEYNOTSET = "766"
|
||||||
RPL_SASLSUCCESS = "903"
|
RPL_METADATASUBOK = "770"
|
||||||
ERR_SASLFAIL = "904"
|
RPL_METADATAUNSUBOK = "771"
|
||||||
ERR_SASLTOOLONG = "905"
|
RPL_METADATASUBS = "772"
|
||||||
ERR_SASLABORTED = "906"
|
RPL_METADATASYNCLATER = "774" // end metadata numerics
|
||||||
ERR_SASLALREADY = "907"
|
RPL_LOGGEDIN = "900"
|
||||||
RPL_SASLMECHS = "908"
|
RPL_LOGGEDOUT = "901"
|
||||||
RPL_REG_SUCCESS = "920"
|
ERR_NICKLOCKED = "902"
|
||||||
RPL_VERIFY_SUCCESS = "923"
|
RPL_SASLSUCCESS = "903"
|
||||||
RPL_REG_VERIFICATION_REQUIRED = "927"
|
ERR_SASLFAIL = "904"
|
||||||
ERR_TOOMANYLANGUAGES = "981"
|
ERR_SASLTOOLONG = "905"
|
||||||
ERR_NOLANGUAGE = "982"
|
ERR_SASLABORTED = "906"
|
||||||
|
ERR_SASLALREADY = "907"
|
||||||
|
RPL_SASLMECHS = "908"
|
||||||
|
ERR_TOOMANYLANGUAGES = "981"
|
||||||
|
ERR_NOLANGUAGE = "982"
|
||||||
)
|
)
|
||||||
|
|||||||
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,11 +3,15 @@
|
|||||||
|
|
||||||
package passwd
|
package passwd
|
||||||
|
|
||||||
import "golang.org/x/crypto/bcrypt"
|
import (
|
||||||
import "golang.org/x/crypto/sha3"
|
"crypto/sha3"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MinCost = bcrypt.MinCost
|
MinCost = bcrypt.MinCost
|
||||||
|
MaxCost = bcrypt.MaxCost
|
||||||
DefaultCost = 12 // ballpark: 250 msec on a modern Intel CPU
|
DefaultCost = 12 // ballpark: 250 msec on a modern Intel CPU
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,16 @@ func TestBasic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVector(t *testing.T) {
|
||||||
|
// sanity check for persisted hashes
|
||||||
|
if CompareHashAndPassword(
|
||||||
|
[]byte("$2a$12$sJokyLJ5px3Nb51DEDhsQ.wh8nfwEYuMbVYrpqO5v9Ylyj0YyVWj."),
|
||||||
|
[]byte("this is my passphrase"),
|
||||||
|
) != nil {
|
||||||
|
t.Errorf("hash comparison failed unexpectedly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLongPassphrases(t *testing.T) {
|
func TestLongPassphrases(t *testing.T) {
|
||||||
longPassphrase := make([]byte, 168)
|
longPassphrase := make([]byte, 168)
|
||||||
for i := range longPassphrase {
|
for i := range longPassphrase {
|
||||||
|
|||||||
41
irc/postgresql/config.go
Normal file
41
irc/postgresql/config.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) 2020 Shivaram Lingamneni
|
||||||
|
// released under the MIT license
|
||||||
|
|
||||||
|
package postgresql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maximum length in bytes of any message target (nickname or channel name) in its
|
||||||
|
// canonicalized (i.e., casefolded) state:
|
||||||
|
MaxTargetLength = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
// these are intended to be written directly into the config file:
|
||||||
|
Enabled bool
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
SocketPath string `yaml:"socket-path"`
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
HistoryDatabase string `yaml:"history-database"`
|
||||||
|
Timeout time.Duration
|
||||||
|
MaxConns int `yaml:"max-conns"`
|
||||||
|
ConnMaxLifetime time.Duration `yaml:"conn-max-lifetime"`
|
||||||
|
// PostgreSQL-specific configuration:
|
||||||
|
ApplicationName string `yaml:"application-name"` // shown in pg_stat_activity
|
||||||
|
ConnectTimeout time.Duration `yaml:"connect-timeout"` // timeout for establishing connections
|
||||||
|
// PostgreSQL SSL/TLS configuration:
|
||||||
|
SSLMode string `yaml:"ssl-mode"` // disable, require, verify-ca, verify-full
|
||||||
|
SSLCert string `yaml:"ssl-cert"` // client certificate path
|
||||||
|
SSLKey string `yaml:"ssl-key"` // client key path
|
||||||
|
SSLRootCert string `yaml:"ssl-root-cert"` // CA certificate path
|
||||||
|
URI string `yaml:"uri"` // libpq postgresql:// URI overriding the above
|
||||||
|
|
||||||
|
// XXX these are copied from elsewhere in the config:
|
||||||
|
ExpireTime time.Duration
|
||||||
|
TrackAccountMessages bool
|
||||||
|
}
|
||||||
86
irc/postgresql/config_test.go
Normal file
86
irc/postgresql/config_test.go
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
//go:build postgres
|
||||||
|
|
||||||
|
// Copyright (c) 2020 Shivaram Lingamneni
|
||||||
|
// released under the MIT license
|
||||||
|
|
||||||
|
package postgresql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testBuildURI(t *testing.T, config Config, expected string) {
|
||||||
|
t.Helper()
|
||||||
|
uri, err := config.buildURI()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if uri != expected {
|
||||||
|
t.Errorf("got %q, want %q", uri, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildURITCP(t *testing.T) {
|
||||||
|
testBuildURI(t, Config{
|
||||||
|
Host: "db.example.com",
|
||||||
|
Port: 5432,
|
||||||
|
User: "ergo",
|
||||||
|
Password: "secret",
|
||||||
|
HistoryDatabase: "ergo_history",
|
||||||
|
}, "postgresql://ergo:secret@db.example.com:5432/ergo_history?sslmode=disable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildURIDefaultPort(t *testing.T) {
|
||||||
|
testBuildURI(t, Config{
|
||||||
|
Host: "localhost",
|
||||||
|
HistoryDatabase: "ergo_history",
|
||||||
|
}, "postgresql://localhost:5432/ergo_history?sslmode=disable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildURIDefaultHost(t *testing.T) {
|
||||||
|
testBuildURI(t, Config{
|
||||||
|
HistoryDatabase: "ergo_history",
|
||||||
|
}, "postgresql://localhost:5432/ergo_history?sslmode=disable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildURISSLMode(t *testing.T) {
|
||||||
|
testBuildURI(t, Config{
|
||||||
|
Host: "db.example.com",
|
||||||
|
Port: 5432,
|
||||||
|
HistoryDatabase: "ergo_history",
|
||||||
|
SSLMode: "verify-full",
|
||||||
|
SSLCert: "/etc/ssl/client.crt",
|
||||||
|
SSLKey: "/etc/ssl/client.key",
|
||||||
|
SSLRootCert: "/etc/ssl/ca.crt",
|
||||||
|
}, "postgresql://db.example.com:5432/ergo_history?sslcert=%2Fetc%2Fssl%2Fclient.crt&sslkey=%2Fetc%2Fssl%2Fclient.key&sslmode=verify-full&sslrootcert=%2Fetc%2Fssl%2Fca.crt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildURIUnixSocket(t *testing.T) {
|
||||||
|
testBuildURI(t, Config{
|
||||||
|
SocketPath: "/var/run/postgresql",
|
||||||
|
User: "ergo",
|
||||||
|
Password: "secret",
|
||||||
|
HistoryDatabase: "ergo_history",
|
||||||
|
}, "postgresql://ergo:secret@/ergo_history?host=%2Fvar%2Frun%2Fpostgresql")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildURISpecialCharsInPassword(t *testing.T) {
|
||||||
|
testBuildURI(t, Config{
|
||||||
|
Host: "db.example.com",
|
||||||
|
Port: 5432,
|
||||||
|
User: "ergo",
|
||||||
|
Password: "p@ss:w/ord?#&=",
|
||||||
|
HistoryDatabase: "ergo_history",
|
||||||
|
}, "postgresql://ergo:p%40ss%3Aw%2Ford%3F%23&=@db.example.com:5432/ergo_history?sslmode=disable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildURIOptionalParams(t *testing.T) {
|
||||||
|
testBuildURI(t, Config{
|
||||||
|
Host: "db.example.com",
|
||||||
|
Port: 5433,
|
||||||
|
HistoryDatabase: "ergo_history",
|
||||||
|
ApplicationName: "ergo",
|
||||||
|
ConnectTimeout: 30 * time.Second,
|
||||||
|
}, "postgresql://db.example.com:5433/ergo_history?application_name=ergo&connect_timeout=30&sslmode=disable")
|
||||||
|
}
|
||||||
1200
irc/postgresql/history.go
Normal file
1200
irc/postgresql/history.go
Normal file
File diff suppressed because it is too large
Load Diff
31
irc/postgresql/stub.go
Normal file
31
irc/postgresql/stub.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
//go:build !postgresql
|
||||||
|
|
||||||
|
// Copyright (c) 2020 Shivaram Lingamneni
|
||||||
|
// released under the MIT license
|
||||||
|
|
||||||
|
package postgresql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/history"
|
||||||
|
"github.com/ergochat/ergo/irc/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enabled is false when PostgreSQL support is not compiled in
|
||||||
|
const Enabled = false
|
||||||
|
|
||||||
|
// PostgreSQL is a stub implementation when the postgres build tag is not present
|
||||||
|
type PostgreSQL struct {
|
||||||
|
history.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostgreSQLDatabase returns an error when PostgreSQL support is not compiled in
|
||||||
|
func NewPostgreSQLDatabase(logger *logger.Manager, config Config) (*PostgreSQL, error) {
|
||||||
|
return nil, errors.New("PostgreSQL support not enabled in this build. Rebuild with `make build_full` to enable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfig is a no-op for the stub implementation
|
||||||
|
func (pg *PostgreSQL) SetConfig(config Config) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
@ -193,6 +193,9 @@ func (rb *ResponseBuffer) sendBatchEnd(blocking bool) {
|
|||||||
// Starts a nested batch (see the ResponseBuffer struct definition for a description of
|
// Starts a nested batch (see the ResponseBuffer struct definition for a description of
|
||||||
// how this works)
|
// how this works)
|
||||||
func (rb *ResponseBuffer) StartNestedBatch(batchType string, params ...string) (batchID string) {
|
func (rb *ResponseBuffer) StartNestedBatch(batchType string, params ...string) (batchID string) {
|
||||||
|
if !rb.session.capabilities.Has(caps.Batch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
batchID = rb.session.generateBatchID()
|
batchID = rb.session.generateBatchID()
|
||||||
msgParams := make([]string, len(params)+2)
|
msgParams := make([]string, len(params)+2)
|
||||||
msgParams[0] = "+" + batchID
|
msgParams[0] = "+" + batchID
|
||||||
@ -219,19 +222,6 @@ func (rb *ResponseBuffer) EndNestedBatch(batchID string) {
|
|||||||
rb.AddMessage(ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "-"+batchID))
|
rb.AddMessage(ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "-"+batchID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience to start a nested batch for history lines, at the highest level
|
|
||||||
// supported by the client (`history`, `chathistory`, or no batch, in descending order).
|
|
||||||
func (rb *ResponseBuffer) StartNestedHistoryBatch(params ...string) (batchID string) {
|
|
||||||
var batchType string
|
|
||||||
if rb.session.capabilities.Has(caps.Batch) {
|
|
||||||
batchType = "chathistory"
|
|
||||||
}
|
|
||||||
if batchType != "" {
|
|
||||||
batchID = rb.StartNestedBatch(batchType, params...)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send sends all messages in the buffer to the client.
|
// Send sends all messages in the buffer to the client.
|
||||||
// Afterwards, the buffer is in an undefined state and MUST NOT be used further.
|
// Afterwards, the buffer is in an undefined state and MUST NOT be used further.
|
||||||
// If `blocking` is true you MUST be sending to the client from its own goroutine.
|
// If `blocking` is true you MUST be sending to the client from its own goroutine.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
37
irc/serde.go
Normal file
37
irc/serde.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// Copyright (c) 2022 Shivaram Lingamneni
|
||||||
|
// released under the MIT license
|
||||||
|
|
||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/ergochat/ergo/irc/datastore"
|
||||||
|
"github.com/ergochat/ergo/irc/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Serializable interface {
|
||||||
|
Serialize() ([]byte, error)
|
||||||
|
Deserialize([]byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchAndDeserializeAll[T any, C interface {
|
||||||
|
*T
|
||||||
|
Serializable
|
||||||
|
}](table datastore.Table, dstore datastore.Datastore, log *logger.Manager) (result []T, err error) {
|
||||||
|
rawRecords, err := dstore.GetAll(table)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result = make([]T, len(rawRecords))
|
||||||
|
pos := 0
|
||||||
|
for _, record := range rawRecords {
|
||||||
|
err := C(&result[pos]).Deserialize(record.Value)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("internal", "deserialization error", strconv.Itoa(int(table)), record.UUID.String(), err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
return result[:pos], nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user