3
0
mirror of https://github.com/ergochat/ergo.git synced 2025-08-03 03:07:27 +02:00

Compare commits

..

No commits in common. "master" and "v0.3.0" have entirely different histories.

1143 changed files with 5640 additions and 454580 deletions

View File

@ -1,14 +0,0 @@
#!/bin/bash
# exclude vendor/
SOURCES="./ergo.go ./irc"
if [ "$1" = "--fix" ]; then
exec gofmt -s -w $SOURCES
fi
if [ -n "$(gofmt -s -l $SOURCES)" ]; then
echo "Go code is not formatted correctly with \`gofmt -s\`:"
gofmt -s -d $SOURCES
exit 1
fi

2
.gitattributes vendored
View File

@ -1,2 +0,0 @@
vendor/* linguist-vendored
languages/* linguist-vendored

View File

@ -1,32 +0,0 @@
name: "build"
on:
pull_request:
branches:
- "master"
- "stable"
push:
branches:
- "master"
- "stable"
jobs:
build:
runs-on: "ubuntu-24.04"
steps:
- name: "checkout repository"
uses: "actions/checkout@v3"
- name: "setup go"
uses: "actions/setup-go@v3"
with:
go-version: "1.24"
- name: "install python3-pytest"
run: "sudo apt install -y python3-pytest"
- name: "make install"
run: "make install"
- name: "make test"
run: "make test"
- name: "make smoke"
run: "make smoke"
- name: "make irctest"
run: "make irctest"

View File

@ -1,48 +0,0 @@
name: 'ghcr'
on:
push:
branches:
- "master"
- "stable"
tags:
- 'v*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout Git repository
uses: actions/checkout@v3
- name: Authenticate to container registry
uses: docker/login-action@v2
if: github.event_name != 'pull_request'
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Setup Docker buildx driver
id: buildx
uses: docker/setup-buildx-action@v2
- name: Build and publish image
uses: docker/build-push-action@v3
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

13
.gitignore vendored
View File

@ -62,8 +62,6 @@ Temporary Items
# Linux trash folder which might appear on any partition or disk
.Trash-*
# vim swapfiles
*.swp
### Go ###
# Compiled Object files, Static and Dynamic libs (Shared Objects)
@ -95,19 +93,12 @@ _testmain.go
*.out
### custom ###
### Oragono ###
/_site/
/.vscode/*
/ircd*
/web-*
/web.*
/ssl.*
/tls.*
/ergo
/oragono
/build/*
_test
ergo.prof
ergo.mprof
/dist
*.pem
.dccache

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "irctest"]
path = irctest
url = https://github.com/ergochat/irctest

View File

@ -1,82 +0,0 @@
# .goreleaser.yml
# Build customization
version: 2
project_name: ergo
builds:
- main: ergo.go
env:
- CGO_ENABLED=0
binary: ergo
goos:
- linux
- windows
- darwin
- freebsd
- openbsd
- plan9
goarch:
- amd64
- arm
- arm64
- riscv64
goarm:
- 6
ignore:
- goos: windows
goarch: arm
- goos: windows
goarch: arm64
- goos: windows
goarch: riscv64
- goos: darwin
goarch: arm
- goos: darwin
goarch: riscv64
- goos: freebsd
goarch: arm
- goos: freebsd
goarch: arm64
- goos: freebsd
goarch: riscv64
- goos: openbsd
goarch: arm
- goos: openbsd
goarch: arm64
- goos: openbsd
goarch: riscv64
- goos: plan9
goarch: arm
- goos: plan9
goarch: arm64
- goos: plan9
goarch: riscv64
flags:
- -trimpath
archives:
-
name_template: >-
{{ .ProjectName }}-{{ .Version }}-
{{- 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_overrides:
- goos: windows
format: zip
files:
- README
- CHANGELOG.md
- LICENSE
- ergo.motd
- default.yaml
- traditional.yaml
- docs/API.md
- docs/MANUAL.md
- docs/USERGUIDE.md
- languages/*.yaml
- languages/*.json
- languages/*.md
wrap_in_directory: true
checksum:
name_template: "{{ .ProjectName }}-{{ .Version }}-checksums.txt"

File diff suppressed because it is too large Load Diff

View File

@ -1,213 +0,0 @@
# Developing Ergo
This is a guide to modifying Ergo's code. If you're just trying to run your own Ergo, or use one, you shouldn't need to worry about these issues.
## Golang issues
You should use the [latest distribution of the Go language for your OS and architecture](https://golang.org/dl/). (If `uname -m` on your Raspberry Pi reports `armv7l`, use the `armv6l` distribution of Go; if it reports v8, you may be able to use the `arm64` distribution.)
Ergo vendors all its dependencies. Because of this, Ergo is self-contained and you should not need to fetch any dependencies with `go get`. Doing so is not recommended, since it may fetch incompatible versions of the dependencies.
If you're upgrading the Go version used by Ergo, there are several places where it's hard-coded and must be changed:
1. `.github/workflows/build.yml`, which controls the version that our CI test suite uses to build and test the code (e.g., for a PR)
2. `Dockerfile`, which controls the version that the Ergo binaries in our Docker images are built with
3. `go.mod`: this should be updated automatically by Go when you do module-related operations
## Branches
The recommended workflow for development is to create a new branch starting from the current `master`. Even though `master` is not recommended for production use, we strive to keep it in a usable state. Starting from `master` increases the likelihood that your patches will be accepted.
Long-running feature branches that aren't ready for merge into `master` may be maintained under a `devel+` prefix, e.g. `devel+metadata` for a feature branch implementing the IRCv3 METADATA extension.
## Workflow
We have two test suites:
1. `make test`, which runs some relatively shallow unit tests, checks `go vet`, and does some other internal consistency checks
1. `make irctest`, which runs the [irctest](https://github.com/ProgVal/irctest) integration test suite
Barring special circumstances, both must pass for a PR to be accepted. irctest will test the `ergo` binary visible on `$PATH`; make sure your development version is the one being tested. (If you have `~/go/bin` on your `$PATH`, a successful `make install` will accomplish this.)
The project style is [gofmt](https://go.dev/blog/gofmt); it is enforced by `make test`. You can fix any style issues automatically by running `make gofmt`.
## Updating dependencies
Ergo vendors all dependencies using `go mod vendor`. To update a dependency, or add a new one:
1. `go get -v bazbat.com/path/to/dependency` ; this downloads the new dependency
2. `go mod vendor` ; this writes the dependency's source files to the `vendor/` directory
3. `git add go.mod go.sum vendor/` ; this stages all relevant changes to the vendor directory, including file deletions. Take care that spurious changes (such as editor swapfiles) aren't added.
4. `git commit`
## Releasing a new version
1. Ensure the tests pass, locally on travis (`make test`, `make smoke`, and `make irctest`)
1. Test backwards compatibility guarantees. Get an example config file and an example database from the previous stable release. Make sure the current build still works with them (modulo anything explicitly called out in the changelog as a breaking change).
1. Run the `ircstress` chanflood benchmark to look for data races (enable race detection) and performance regressions (disable it).
1. Update the changelog with new changes and write release notes.
1. Update the version number `irc/version.go` (either change `-unreleased` to `-rc1`, or remove `-rc1`, as appropriate).
1. Commit the new changelog and constants change.
1. Tag the release with `git tag --sign v0.0.0 -m "Release v0.0.0"` (`0.0.0` replaced with the real ver number).
1. Build binaries using `make release`
1. Sign the checksums file with `gpg --sign --detach-sig --local-user <fingerprint>`
1. Smoke-test a built binary locally
1. Point of no return: `git push origin master --tags` (this publishes the tag; any fixes after this will require a new point release)
1. Publish the release on GitHub (Releases -> "Draft a new release"); use the new tag, post the changelog entries, upload the binaries, the checksums file, and the signature of the checksums file
1. Update the `irctest_stable` branch with the new changes (this may be a force push).
1. If it's a production release (as opposed to a release candidate), update the `stable` branch with the new changes. (This may be a force push in the event that stable contained a backport. This is fine because all stable releases and release candidates are tagged.)
1. Similarly, for a production release, update the `irctest_stable` branch (this is the branch used by upstream irctest to integration-test against Ergo).
1. Make the appropriate announcements:
* For a release candidate:
1. the channel topic
1. any operators who may be interested
1. update the testnet
* For a production release:
1. everything applicable to a release candidate
1. Twitter
1. ergo.chat/news
1. ircv3.net support tables, if applicable
1. other social media?
Once it's built and released, you need to setup the new development version. To do so:
1. Ensure dependencies are up-to-date.
1. Bump the version number in `irc/version.go`, typically by incrementing the second number in the 3-tuple, and add '-unreleased' (for instance, `2.2.0` -> `2.3.0-unreleased`).
1. Commit the new version number and changelog with the message `"Setup v0.0.1-unreleased devel ver"`.
**Unreleased changelog content**
```md
## Unreleased
New release of Ergo!
### Config Changes
### Security
### Added
### Changed
### Removed
### Fixed
```
## Debugging
It's helpful to enable all loglines while developing. Here's how to configure this:
```yaml
logging:
-
method: stderr
type: "*"
level: debug
```
To debug a hang, the best thing to do is to get a stack trace. The easiest way to get stack traces is with the [pprof listener](https://golang.org/pkg/net/http/pprof/), which can be enabled in the `debug` section of the config. Once it's enabled, you can navigate to `http://localhost:6060/debug/pprof/` in your browser and go from there. If that doesn't work, try:
$ kill -ABRT <procid>
This will kill Ergo and print out a stack trace for you to take a look at.
## Concurrency design
Ergo involves a fair amount of shared state. Here are some of the main points:
1. Each client has a separate goroutine that listens for incoming messages and synchronously processes them.
1. All sends to clients are asynchronous; `client.Send` appends the message to a queue, which is then processed on a separate goroutine. It is always safe to call `client.Send`.
1. The server has a few of its own goroutines, for listening on sockets and handing off new client connections to their dedicated goroutines.
1. A few tasks are done asynchronously in ad-hoc goroutines.
In consequence, there is a lot of state (in particular, server and channel state) that can be read and written from multiple goroutines. This state is protected with mutexes. To avoid deadlocks, mutexes are arranged in "tiers"; while holding a mutex of one tier, you're only allowed to acquire mutexes of a strictly *higher* tier. The tiers are:
1. Tier 1 mutexes: these are the "innermost" mutexes. They typically protect getters and setters on objects, or invariants that are local to the state of a single object. Example: `Channel.stateMutex`.
1. Tier 2 mutexes: these protect some invariants of their own, but also need to access fields on other objects that themselves require synchronization. Example: `ChannelManager.RWMutex`.
1. Tier 3 mutexes: these protect macroscopic operations, where it doesn't make sense for more than one to occur concurrently. Example; `Server.rehashMutex`, which prevents rehashes from overlapping.
There are some mutexes that are "tier 0": anything in a subpackage of `irc` (e.g., `irc/logger` or `irc/connection_limits`) shouldn't acquire mutexes defined in `irc`.
We are using `buntdb` for persistence; a `buntdb.DB` has an `RWMutex` inside it, with read-write transactions getting the `Lock()` and read-only transactions getting the `RLock()`. This mutex is considered tier 1. However, it's shared globally across all consumers, so if possible you should avoid acquiring it while holding ordinary application-level mutexes.
## Command handlers and ResponseBuffer
We support a lot of IRCv3 specs. Pretty much all of them, in fact. And a lot of proposed/draft ones. One of the draft specifications that we support is called ["labeled responses"](https://ircv3.net/specs/extensions/labeled-response.html).
With labeled responses, when a client sends a label along with their command, they are assured that they will receive the response messages with that same label.
For example, if the client sends this to the server:
@label=pQraCjj82e PRIVMSG #channel :hi!
They will expect to receive this (with echo-message also enabled):
@label=pQraCjj82e :nick!user@host PRIVMSG #channel :hi!
They receive the response with the same label, so they can match the sent command to the received response. They can also do the same with any other command.
In order to allow this, in command handlers we don't send responses directly back to the user. Instead, we buffer the responses in an object called a ResponseBuffer. When the command handler returns, the contents of the ResponseBuffer is sent to the user with the appropriate label (and batches, if they're required).
Basically, if you're in a command handler and you're sending a response back to the requesting client, use `rb.Add*` instead of `client.Send*`. Doing this makes sure the labeled responses feature above works as expected. The handling around `PRIVMSG`/`NOTICE`/`TAGMSG` is strange, so simply defer to [irctest](https://github.com/DanielOaks/irctest)'s judgement about whether that's correct for the most part.
## Translated strings
The function `client.t()` is used fairly widely throughout the codebase. This function translates the given string using the client's negotiated language. If the parameter of the function is a string, the translation update script below will grab that string and mark it for translation.
In addition, throughout most of the codebase, if a string is created using the backtick characters ``(`)``, that string will also be marked for translation. This is really useful in the cases of general errors and other strings that are created far away from the final `client.t` function they are sent through.
## Updating Translations
We support translating server strings using [CrowdIn](https://crowdin.com/project/ergochat)! To send updated source strings to CrowdIn, you should:
1. `cd` to the base directory (the one this `DEVELOPING` file is in).
2. Install the `pyyaml` and `docopt` deps using `pip3 install pyyamp docopt`.
3. Run the `updatetranslations.py` script with: `./updatetranslations.py run irc languages`
4. Commit the changes
CrowdIn's integration should grab the new translation files automagically.
When new translations are available, CrowsIn will submit a new PR with the updates. The `INFO` command should be used to see whether the credits strings has been updated/translated properly, since that can be a bit of a sticking point for our wonderful translators :)
### Updating Translations Manually
You shouldn't need to do this, but to update 'em manually:
1. `cd` to the base directory (the one this `DEVELOPING` file is in).
2. Install the `pyyaml` and `docopt` deps using `pip3 install pyyamp docopt`.
3. Run the `updatetranslations.py` script with: `./updatetranslations.py run irc languages`
4. Install the [CrowdIn CLI tool](https://support.crowdin.com/cli-tool/).
5. Make sure the CrowdIn API key is correct in `~/.crowdin.yaml`
6. Run `crowdin upload sources`
We also support grabbing translations directly from CrowdIn. To do this:
1. `cd` to the base directory (the one this `DEVELOPING` file is in).
2. Install the [CrowdIn CLI tool](https://support.crowdin.com/cli-tool/).
3. Make sure the CrowdIn API key is correct in `~/.crowdin.yaml`
4. Run `crowdin download`
This will download a bunch of updated files and put them in the right place
## Adding a mode
When adding a mode, keep in mind the following places it may need to be referenced:
1. The mode needs to be defined in the `irc/modes` subpackage
1. It may need to be special-cased in `modes.RplMyInfo()`
1. It may need to be added to the `CHANMODES` ISUPPORT token
1. It may need special handling in `ApplyUserModeChanges` or `ApplyChannelModeChanges`
1. It may need special persistence handling code

View File

@ -1,48 +0,0 @@
## build ergo binary
FROM docker.io/golang:1.24-alpine AS build-env
RUN apk upgrade -U --force-refresh --no-cache && apk add --no-cache --purge --clean-protected -l -u make git
# copy ergo source
WORKDIR /go/src/github.com/ergochat/ergo
COPY . .
# modify default config file so that it doesn't die on IPv6
# and so it can be exposed via 6667 by default
RUN sed -i 's/^\(\s*\)\"127.0.0.1:6667\":.*$/\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
RUN make install
## build ergo container
FROM docker.io/alpine:3.19
# metadata
LABEL maintainer="Daniel Oaks <daniel@danieloaks.net>,Daniel Thamdrup <dallemon@protonmail.com>" \
description="Ergo is a modern, experimental IRC server written in Go"
# standard ports listened on
EXPOSE 6667/tcp 6697/tcp
# ergo itself
COPY --from=build-env /go/bin/ergo \
/go/src/github.com/ergochat/ergo/default.yaml \
/go/src/github.com/ergochat/ergo/distrib/docker/run.sh \
/ircd-bin/
COPY --from=build-env /go/src/github.com/ergochat/ergo/languages /ircd-bin/languages/
# running volume holding config file, db, certs
VOLUME /ircd
WORKDIR /ircd
# default motd
COPY --from=build-env /go/src/github.com/ergochat/ergo/ergo.motd /ircd/ergo.motd
# launch
ENTRYPOINT ["/ircd-bin/run.sh"]
# # uncomment to debug
# RUN apk add --no-cache bash
# RUN apk add --no-cache vim
# CMD /bin/bash

View File

@ -1,9 +1,6 @@
The MIT License (MIT)
Copyright (c) 2012-2014 Jeremy Latt
Copyright (c) 2014-2015 Edmund Huber
Copyright (c) 2016-2020 Daniel Oaks
Copyright (c) 2017-2020 Shivaram Lingamneni
Copyright (c) 2014 Jeremy Latt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

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

62
README
View File

@ -1,62 +0,0 @@
___ _ __ __ _ ___
/ _ \ '__/ _` |/ _ \
| __/ | | (_| | (_) |
\___|_| \__, |\___/
__/ |
|___/
-----------------------------------------------------------------------------------------------
Ergo is a modern IRC server written in Go. Its core design principles are:
* Being simple to set up and use
* Combining the features of an ircd, a services framework, and a bouncer:
* Integrated account management
* History storage
* Bouncer functionality
* Bleeding-edge IRCv3 support
* High customizability via a rehashable (i.e., reloadable at runtime) YAML config
https://ergo.chat/
https://github.com/ergochat/ergo
#ergo on irc.ergo.chat or irc.libera.chat
-----------------------------------------------------------------------------------------------
=== Installing ===
Copy the example config file to ircd.yaml with a command like:
$ cp default.yaml ircd.yaml
Modify the config file as needed (the recommendations at the top may be helpful).
To generate passwords for opers and connect passwords, you can use this command:
$ ./ergo genpasswd
If you need to generate self-signed TLS certificates, use this command:
$ ./ergo mkcerts
You are now ready to start Ergo!
$ ./ergo run
For further instructions, consult the manual. A copy of the manual should be
included in your release under `docs/MANUAL.md`. Or you can view it on the
Web: https://ergo.chat/manual.html
=== Updating ===
If you're updating from a previous version of Ergo, check out the CHANGELOG for a list
of important changes you'll want to take a look at. The change log details config changes,
fixes, new features and anything else you'll want to be aware of!
=== Credits ===
* Jeremy Latt (2012-2014)
* Edmund Huber (2014-2015)
* Daniel Oaks (2016-present)
* Shivaram Lingamneni (2017-present)
* Many other contributors and friends of the project <3

135
README.md
View File

@ -1,127 +1,68 @@
![Ergo logo](docs/logo.png)
![Oragono logo](docs/logo.png)
Ergo (formerly known as Oragono) is a modern IRC server written in Go. Its core design principles are:
Oragono is a modern, experimental IRC server written in Go. It's designed to be simple to setup and use, and to provide the majority of features that IRC users expect today.
* Being simple to set up and use
* Combining the features of an ircd, a services framework, and a bouncer (integrated account management, history storage, and bouncer functionality)
* Bleeding-edge [IRCv3 support](https://ircv3.net/software/servers.html), suitable for use as an IRCv3 reference implementation
* High customizability via a rehashable (i.e., reloadable at runtime) YAML config
It includes features such as UTF-8 nicks and channel names, client accounts and SASL, and other assorted IRCv3 support.
Ergo is a fork of the [Ergonomadic](https://github.com/jlatt/ergonomadic) IRC daemon <3
Oragono is a fork of the [Ergonomadic](https://github.com/edmund-huber/ergonomadic) IRC daemon <3
Also see the [mammon](https://github.com/mammon-ircd/mammon) IRC daemon for a similar project written in Python instead.
---
[![Go Report Card](https://goreportcard.com/badge/github.com/ergochat/ergo)](https://goreportcard.com/report/github.com/ergochat/ergo)
[![build](https://github.com/ergochat/ergo/actions/workflows/build.yml/badge.svg)](https://github.com/ergochat/ergo/actions/workflows/build.yml)
[![Download Latest Release](https://img.shields.io/badge/downloads-latest%20release-green.svg)](https://github.com/ergochat/ergo/releases/latest)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/ergochat/localized.svg)](https://crowdin.com/project/ergochat)
If you want to take a look at a running Ergo instance or test some client code, feel free to play with [testnet.ergo.chat](https://testnet.ergo.chat/) (TLS on port 6697 or plaintext on port 6667).
[![Go Report Card](https://goreportcard.com/badge/github.com/DanielOaks/oragono)](https://goreportcard.com/report/github.com/DanielOaks/oragono)
---
This project adheres to [Semantic Versioning](http://semver.org/). For the purposes of versioning, we consider the "public API" to refer to the configuration files, CLI interface and database format.
## Features
* integrated services: NickServ for user accounts, ChanServ for channel registration, and HostServ for vanity hosts
* bouncer-like features: storing and replaying history, allowing multiple clients to use the same nickname
* native TLS/SSL support, including support for client certificates
* [IRCv3 support](https://ircv3.net/software/servers.html)
* [yaml](https://yaml.org/) configuration
* updating server config and TLS certificates on-the-fly (rehashing)
* SASL authentication
* [LDAP support](https://github.com/ergochat/ergo-ldap)
* supports [multiple languages](https://crowdin.com/project/ergochat) (you can also set a default language for your network)
* optional support for UTF-8 nick and channel names with RFC 8265 (PRECIS)
* advanced security and privacy features (support for requiring SASL for all logins, cloaking IPs, and running as a Tor hidden service)
* an extensible privilege system for IRC operators
* UTF-8 nick and channel names with rfc7700
* [yaml](http://yaml.org/) configuration
* native TLS/SSL support
* server password (`PASS` command)
* channels with most standard modes
* IRC operators
* ident lookups for usernames
* automated client connection limits
* passwords stored with [bcrypt](https://godoc.org/golang.org/x/crypto)
* `UBAN`, a unified ban system that can target IPs, networks, masks, and registered accounts (`KLINE` and `DLINE` are also supported)
* a focus on developing with [specifications](https://ergo.chat/specs.html)
* passwords stored in [bcrypt][go-crypto] format
* client accounts and SASL
* IRCv3 support
For more detailed information on Ergo's functionality, see:
* [MANUAL.md, the operator manual](https://github.com/ergochat/ergo/blob/stable/docs/MANUAL.md)
* [USERGUIDE.md, the guide for end users](https://github.com/ergochat/ergo/blob/stable/docs/USERGUIDE.md)
## Quick start guide
Download the latest release from this page: https://github.com/ergochat/ergo/releases/latest
Extract it into a folder, then run the following commands:
## Installation
```sh
cp default.yaml ircd.yaml
go get
go install
cp oragono.yaml ircd.yaml
vim ircd.yaml # modify the config file to your liking
./ergo mkcerts
./ergo run # server should be ready to go!
oragono initdb
oragono mkcerts
```
**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.
### Platform Packages
Some platforms/distros also have Ergo packages maintained for them:
* Arch Linux [AUR](https://aur.archlinux.org/packages/ergochat/) - Maintained by [Jason Papakostas (@vith)](https://github.com/vith).
* [Gentoo Linux](https://packages.gentoo.org/packages/net-irc/ergo) - Maintained by [Sam James (@thesamesam)](https://github.com/thesamesam).
### Using Docker
A Dockerfile and example docker-compose recipe are available in the `distrib/docker` directory. Ergo is automatically published
to the GitHub Container Registry at [ghcr.io/ergochat/ergo](https://ghcr.io/ergochat/ergo). For more information, see the distrib/docker
[README file](https://github.com/ergochat/ergo/blob/master/distrib/docker/README.md).
### From Source
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.
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.
For information on contributing to Ergo, see [DEVELOPING.md](https://github.com/ergochat/ergo/blob/master/DEVELOPING.md).
#### Building
You'll need an [up-to-date distribution of the Go language for your OS and architecture](https://golang.org/dl/). Once that's installed (check the output of `go version`), just check out your desired branch or tag and run `make`. This will produce an executable binary named `ergo` in the base directory of the project. (Ergo vendors all its dependencies, so you will not need to fetch any dependencies remotely.)
**Note:** This installation will give you unsigned certificates only suitable for teting purposes.
For real crets, look into [Let's Encrypt](https://letsencrypt.org/).
## Configuration
The default config file [`default.yaml`](default.yaml) helps walk you through what each option means and changes.
You can use the `--conf` parameter when launching Ergo to control where it looks for the config file. For instance: `ergo run --conf /path/to/ircd.yaml`. The configuration file also stores where the log, database, certificate, and other files are opened. Normally, all these files use relative paths, but you can change them to be absolute (such as `/var/log/ircd.log`) when running Ergo as a service.
### Logs
By default, logs go to stderr only. They can be configured to go to a file, or you can use systemd to direct the stderr to the system journal (see the manual for details). The configuration format of logs is designed to be easily pluggable, and is inspired by the logging config provided by InspIRCd.
### Passwords
Passwords (for both `PASS` and oper logins) are stored using bcrypt. To generate encrypted strings for use in the config, use the `genpasswd` subcommand as such:
See the example [`oragono.yaml`](oragono.yaml). Passwords are stored using bcrypt. You can generate encrypted password strings for use in the config with the `genpasswd` subcommand.
```sh
ergo genpasswd
oragono genpasswd
```
With this, you receive a blob of text which you can plug into your configuration file.
## Running the server
### Nickname and channel registration
```sh
oragono run
```
Ergo relies heavily on user accounts to enable its distinctive features (such as allowing multiple clients per nickname). As a user, you can register your current nickname as an account using `/msg NickServ register <password>`. Once you have done so, you should [enable SASL in your clients](https://libera.chat/guides/sasl), ensuring that you will be automatically logged into your account on each connection. This will prevent [problems claiming your registered nickname](https://github.com/ergochat/ergo/blob/master/docs/MANUAL.md#nick-equals-account).
## Credits
Once you have registered your nickname, you can use it to register channels:
* Jeremy Latt, creator of Ergonomadic, <https://github.com/jlatt>
* Edmund Huber, maintainer of Ergonomadic, <https://github.com/edmund-huber>
* Niels Freier, added WebSocket support to Ergonomadic, <https://github.com/stumpyfr>
* Daniel Oakley, maintainer of Oragono, <https://github.com/DanielOaks>
* apologies to anyone I forgot.
1. Join the channel with `/join #channel`
2. Register the channel with `/CS REGISTER #channel`
After this, your channel will remember the fact that you're the owner, the topic, and any modes set on it!
# Credits
* Jeremy Latt (2012-2014)
* Edmund Huber (2014-2015)
* Daniel Oaks (2016-present)
* Shivaram Lingamneni (2017-present)
* [Many other contributors and friends of the project <3](https://github.com/ergochat/ergo/blob/master/CHANGELOG.md)
[go-crypto]: https://godoc.org/golang.org/x/crypto

54
build.sh Executable file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env sh
# release build script
# to be run inside the Oragono dir
## windows ##
rm -rf ./build/win/
mkdir -p ./build/win/docs/
GOOS=windows GOATCH=amd64 go build oragono.go
mv oragono.exe ./build/win/
cp LICENSE ./build/win/
cp oragono.yaml oragono.motd ./build/win
cp ./docs/README ./build/win/
cp ./CHANGELOG.md ./build/win/docs
cp ./docs/logo* ./build/win/docs
pushd ./build/win
zip -r ../oragono-XXX-windows.zip *
popd
## osx ##
rm -rf ./build/osx/
mkdir -p ./build/osx/docs/
GOOS=darwin GOATCH=amd64 go build oragono.go
mv oragono ./build/osx/
cp LICENSE ./build/osx/
cp oragono.yaml oragono.motd ./build/osx
cp ./docs/README ./build/osx/
cp ./CHANGELOG.md ./build/osx/docs
cp ./docs/logo* ./build/osx/docs
pushd ./build/osx
tar -czvf ../oragono-XXX-osx.tgz *
popd
## linux ##
rm -rf ./build/linux
mkdir -p ./build/linux/docs/
GOOS=linux GOATCH=amd64 go build oragono.go
mv oragono ./build/linux/
cp LICENSE ./build/linux/
cp oragono.yaml oragono.motd ./build/linux
cp ./docs/README ./build/linux/
cp ./CHANGELOG.md ./build/linux/docs
cp ./docs/logo* ./build/linux/docs
pushd ./build/linux
tar -czvf ../oragono-XXX-linux.tgz *
popd

View File

@ -1,53 +0,0 @@
#
# Your crowdin's credentials
#
"project_identifier" : "oragono"
# "api_key" : ""
# "base_path" : ""
#"base_url" : ""
#
# Choose file structure in crowdin
# e.g. true or false
#
"preserve_hierarchy": true
#
# Files configuration
#
files: [
{
"source" : "/languages/example/translation.lang.yaml",
"translation" : "/languages/%locale%.lang.yaml",
"dest" : "translation.lang.yaml"
},
{
"source" : "/languages/example/irc.lang.json",
"translation" : "/languages/%locale%-irc.lang.json",
"dest" : "irc.lang.json"
},
{
"source" : "/languages/example/help.lang.json",
"translation" : "/languages/%locale%-help.lang.json",
"dest" : "help.lang.json",
"update_option" : "update_as_unapproved",
},
{
"source" : "/languages/example/chanserv.lang.json",
"translation" : "/languages/%locale%-chanserv.lang.json",
"dest" : "services/chanserv.lang.json",
"update_option" : "update_as_unapproved",
},
{
"source" : "/languages/example/nickserv.lang.json",
"translation" : "/languages/%locale%-nickserv.lang.json",
"dest" : "services/nickserv.lang.json",
"update_option" : "update_as_unapproved",
},
{
"source" : "/languages/example/hostserv.lang.json",
"translation" : "/languages/%locale%-hostserv.lang.json",
"dest" : "services/hostserv.lang.json",
"update_option" : "update_as_unapproved",
},
]

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +0,0 @@
Created 22/11/2021 by georg@lysergic.dev.
This directory contains Service Management Facility service files for ergo.
These files should be compatible with current OpenSolaris / Illumos based operating systems. Tested on OpenIndiana.
Prerequesites:
- ergo binary located at /opt/ergo/ergo
- ergo configuration located at /opt/ergo/ircd.yaml (hardcoded)
- ergo languages located at /opt/ergo/languages (to be compatible with default.yaml - you may adjust this path or disable languages in your custom ircd.yaml)
- ergo certificate and key located at /opt/ergo/fullchain.pem /opt/ergo/privkey.pem (to be compatible with default.yaml - you may adjust these paths in your custom ircd.yaml)
- `ergo` role user and `ergo` role group owning all of the above
Installation:
- cp ergo.xml /lib/svc/manifest/network/
- cp ergo /lib/svc/method/
- svcadm restart manifest-import
Usage:
- svcadm enable ergo (Start)
- tail /var/svc/log/network-ergo:default.log (Check ergo log and SMF output)
- svcs ergo (Check status)
- svcadm refresh ergo (Reload manifest and ergo configuration)
- svcadm disable ergo (Stop)
Notes:
- Does not support multiple instances - spawns instance :default

View File

@ -1,26 +0,0 @@
#!/sbin/sh
#
# SMF method script for ergo - used by manifest file ergo.xml
# Created 22/11/2021 by georg@lysergic.dev
. /lib/svc/share/smf_include.sh
case $1 in
'start')
exec /opt/ergo/ergo run --conf /opt/ergo/ircd.yaml
;;
'refresh' )
exec pkill -1 -U ergo -x ergo
;;
'stop' )
exec pkill -U ergo -x ergo
;;
*)
echo "Usage: $0 { start | refresh | stop }"
exit 1
;;
esac
exit $?

View File

@ -1,48 +0,0 @@
<?xml version='1.0'?>
<!DOCTYPE service_bundle SYSTEM '/usr/share/lib/xml/dtd/service_bundle.dtd.1'>
<service_bundle type='manifest' name='ergo'>
<service name='network/ergo' type='service' version='0'>
<create_default_instance enabled="true"/>
<single_instance/>
<dependency name='fs-local' grouping='require_all' restart_on='none' type='service'>
<service_fmri value='svc:/system/filesystem/local'/>
</dependency>
<dependency name='fs-autofs' grouping='optional_all' restart_on='none' type='service'>
<service_fmri value='svc:/system/filesystem/autofs'/>
</dependency>
<dependency name='net-loopback' grouping='require_all' restart_on='none' type='service'>
<service_fmri value='svc:/network/loopback'/>
</dependency>
<dependency name='net-physical' grouping='require_all' restart_on='none' type='service'>
<service_fmri value='svc:/network/physical'/>
</dependency>
<dependency name='config_data' grouping='require_all' restart_on='restart' type='path'>
<service_fmri value='file://localhost/opt/ergo/ircd.yaml'/>
</dependency>
<method_context working_directory="/opt/ergo">
<method_credential user='ergo' group='ergo' />
</method_context>
<exec_method name='start' type='method' exec='/lib/svc/method/ergo start' timeout_seconds='20'>
<method_context security_flags='aslr'/>
</exec_method>
<exec_method name='stop' type='method' exec='/lib/svc/method/ergo stop' timeout_seconds='20'/>
<exec_method name='refresh' type='method' exec='/lib/svc/method/ergo refresh' timeout_seconds='20'/>
<property_group name='general' type='framework'>
<propval name='action_authorization' type='astring' value='solaris.smf.manage.ergo'/>
</property_group>
<property_group name='startd' type='framework'>
<propval name='ignore_error' type='astring' value='core,signal'/>
<propval name='duration' type='astring' value='child'/>
</property_group>
<stability value='Unstable'/>
<template>
<common_name>
<loctext xml:lang='C'>IRC server</loctext>
</common_name>
<documentation>
<doc_link name='ergo-manual' uri='https://github.com/ergochat/ergo/blob/master/docs/MANUAL.md'/>
<doc_link name='ergo-userguide' uri='https://github.com/ergochat/ergo/blob/master/docs/USERGUIDE.md'/>
</documentation>
</template>
</service>
</service_bundle>

View File

@ -1,206 +0,0 @@
#!/usr/bin/python3
import binascii
import json
import logging
import re
import sys
from collections import defaultdict, namedtuple
AnopeObject = namedtuple('AnopeObject', ('type', 'kv'))
MASK_MAGIC_REGEX = re.compile(r'[*?!@]')
def access_level_to_amode(level):
# https://wiki.anope.org/index.php/2.0/Modules/cs_xop
if level == 'QOP':
return 'q'
elif level == 'SOP':
return 'a'
elif level == 'AOP':
return 'o'
elif level == 'HOP':
return 'h'
elif level == 'VOP':
return 'v'
try:
level = int(level)
except:
return None
if level >= 10000:
return 'q'
elif level >= 9999:
return 'a'
elif level >= 5:
return 'o'
elif level >= 4:
return 'h'
elif level >= 3:
return 'v'
else:
return None
def to_unixnano(timestamp):
return int(timestamp) * (10**9)
def file_to_objects(infile):
result = []
obj = None
while True:
line = infile.readline()
if not line:
break
line = line.rstrip(b'\r\n')
try:
line = line.decode('utf-8')
except UnicodeDecodeError:
line = line.decode('utf-8', 'replace')
logging.warning("line contained invalid utf8 data " + line)
pieces = line.split(' ', maxsplit=2)
if len(pieces) == 0:
logging.warning("skipping blank line in db")
continue
if pieces[0] == 'END':
result.append(obj)
obj = None
elif pieces[0] == 'OBJECT':
obj = AnopeObject(pieces[1], {})
elif pieces[0] == 'DATA':
obj.kv[pieces[1]] = pieces[2]
elif pieces[0] == 'ID':
# not sure what these do?
continue
else:
raise ValueError("unknown command found in anope db", pieces[0])
return result
ANOPE_MODENAME_TO_MODE = {
'NOEXTERNAL': 'n',
'TOPIC': 't',
'INVITE': 'i',
'NOCTCP': 'C',
'AUDITORIUM': 'u',
'SECRET': 's',
}
# verify that a certfp appears to be a hex-encoded SHA-256 fingerprint;
# if it's anything else, silently ignore it
def validate_certfps(certobj):
certfps = []
for fingerprint in certobj.split():
try:
dec = binascii.unhexlify(fingerprint)
except:
continue
if len(dec) == 32:
certfps.append(fingerprint)
return certfps
def convert(infile):
out = {
'version': 1,
'source': 'anope',
'users': defaultdict(dict),
'channels': defaultdict(dict),
}
objects = file_to_objects(infile)
lastmode_channels = set()
for obj in objects:
if obj.type == 'NickCore':
username = obj.kv['display']
userdata = {'name': username, 'hash': obj.kv['pass'], 'email': obj.kv['email']}
certobj = obj.kv.get('cert')
if certobj:
userdata['certfps'] = validate_certfps(certobj)
out['users'][username] = userdata
elif obj.type == 'NickAlias':
username = obj.kv['nc']
nick = obj.kv['nick']
userdata = out['users'][username]
if username.lower() == nick.lower():
userdata['registeredAt'] = to_unixnano(obj.kv['time_registered'])
else:
if 'additionalNicks' not in userdata:
userdata['additionalNicks'] = []
userdata['additionalNicks'].append(nick)
elif obj.type == 'ChannelInfo':
chname = obj.kv['name']
founder = obj.kv['founder']
chdata = {
'name': chname,
'founder': founder,
'registeredAt': to_unixnano(obj.kv['time_registered']),
'topic': obj.kv['last_topic'],
'topicSetBy': obj.kv['last_topic_setter'],
'topicSetAt': to_unixnano(obj.kv['last_topic_time']),
'amode': {founder: 'q',}
}
# DATA last_modes INVITE KEY,hunter2 NOEXTERNAL REGISTERED TOPIC
last_modes = obj.kv.get('last_modes')
if last_modes:
modes = []
for mode_desc in last_modes.split():
if ',' in mode_desc:
mode_name, mode_value = mode_desc.split(',', maxsplit=1)
else:
mode_name, mode_value = mode_desc, None
if mode_name == 'KEY':
chdata['key'] = mode_value
else:
modes.append(ANOPE_MODENAME_TO_MODE.get(mode_name, ''))
chdata['modes'] = ''.join(modes)
# prevent subsequent ModeLock objects from modifying the mode list further:
lastmode_channels.add(chname)
out['channels'][chname] = chdata
elif obj.type == 'ModeLock':
if obj.kv.get('set') != '1':
continue
chname = obj.kv['ci']
if chname in lastmode_channels:
continue
chdata = out['channels'][chname]
modename = obj.kv['name']
if modename == 'KEY':
chdata['key'] = obj.kv['param']
else:
oragono_mode = ANOPE_MODENAME_TO_MODE.get(modename)
if oragono_mode is not None:
stored_modes = chdata.get('modes', '')
stored_modes += oragono_mode
chdata['modes'] = stored_modes
elif obj.type == 'ChanAccess':
chname = obj.kv['ci']
target = obj.kv['mask']
mode = access_level_to_amode(obj.kv['data'])
if mode is None:
continue
if MASK_MAGIC_REGEX.search(target):
continue
chdata = out['channels'][chname]
amode = chdata.setdefault('amode', {})
amode[target] = mode
chdata['amode'] = amode
# do some basic integrity checks
for chname, chdata in out['channels'].items():
founder = chdata.get('founder')
if founder not in out['users']:
raise ValueError("no user corresponding to channel founder", chname, chdata.get('founder'))
return out
def main():
if len(sys.argv) != 3:
raise Exception("Usage: anope2json.py anope.db output.json")
with open(sys.argv[1], 'rb') as infile:
output = convert(infile)
with open(sys.argv[2], 'w') as outfile:
json.dump(output, outfile)
if __name__ == '__main__':
logging.basicConfig()
sys.exit(main())

View File

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

View File

@ -1,209 +0,0 @@
#!/usr/bin/python3
import binascii
import json
import logging
import re
import sys
from collections import defaultdict
MASK_MAGIC_REGEX = re.compile(r'[*?!@$]')
def to_unixnano(timestamp):
return int(timestamp) * (10**9)
# include/atheme/channels.h
CMODE_FLAG_TO_MODE = {
0x001: 'i', # CMODE_INVITE
0x010: 'n', # CMODE_NOEXT
0x080: 's', # CMODE_SEC
0x100: 't', # CMODE_TOPIC
}
# attempt to interpret certfp as a hex-encoded SHA-256 fingerprint
def validate_certfp(certfp):
try:
dec = binascii.unhexlify(certfp)
except:
return False
return len(dec) == 32
def convert(infile):
out = {
'version': 1,
'source': 'atheme',
'users': defaultdict(dict),
'channels': defaultdict(dict),
}
group_to_founders = defaultdict(list)
channel_to_founder = defaultdict(lambda: (None, None))
while True:
line = infile.readline()
if not line:
break
line = line.rstrip(b'\r\n')
try:
line = line.decode('utf-8')
except UnicodeDecodeError:
line = line.decode('utf-8', 'replace')
logging.warning("line contained invalid utf8 data " + line)
parts = line.split(' ')
category = parts[0]
if category == 'GACL':
# Note: all group definitions precede channel access entries (token CA) by design, so it
# should be safe to read this in using one pass.
groupname = parts[1]
user = parts[2]
flags = parts[3]
if 'F' in flags:
group_to_founders[groupname].append(user)
elif category == 'MU':
# user account
# MU AAAAAAAAB shivaram $1$hcspif$nCm4r3S14Me9ifsOPGuJT. user@example.com 1600134392 1600467343 +sC default
name = parts[2]
user = {'name': name, 'hash': parts[3], 'email': parts[4], 'registeredAt': to_unixnano(parts[5])}
out['users'][name].update(user)
pass
elif category == 'MN':
# grouped nick
# MN shivaram slingamn 1600218831 1600467343
username, groupednick = parts[1], parts[2]
if username != groupednick:
user = out['users'][username]
user.setdefault('additionalnicks', []).append(groupednick)
elif category == 'MDU':
if parts[2] == 'private:usercloak':
username = parts[1]
out['users'][username]['vhost'] = parts[3]
elif category == 'MCFP':
username, certfp = parts[1], parts[2]
if validate_certfp(certfp):
user = out['users'][username]
user.setdefault('certfps', []).append(certfp.lower())
elif category == 'MC':
# channel registration
# MC #mychannel 1600134478 1600467343 +v 272 0 0
# MC #NEWCHANNELTEST 1602270889 1602270974 +vg 1 0 0 jaeger4
chname = parts[1]
chdata = out['channels'][chname]
# XXX just give everyone +nt, regardless of lock status; they can fix it later
chdata.update({'name': chname, 'registeredAt': to_unixnano(parts[2])})
if parts[8] != '':
chdata['key'] = parts[8]
modes = {'n', 't'}
mlock_on, mlock_off = int(parts[5]), int(parts[6])
for flag, mode in CMODE_FLAG_TO_MODE.items():
if flag & mlock_on != 0:
modes.add(mode)
elif flag & mlock_off != 0 and mode in modes:
modes.remove(mode)
chdata['modes'] = ''.join(sorted(modes))
chdata['limit'] = int(parts[7])
elif category == 'MDC':
# auxiliary data for a channel registration
# MDC #mychannel private:topic:setter s
# MDC #mychannel private:topic:text hi again
# MDC #mychannel private:topic:ts 1600135864
chname = parts[1]
category = parts[2]
if category == 'private:topic:text':
out['channels'][chname]['topic'] = line.split(maxsplit=3)[3]
elif category == 'private:topic:setter':
out['channels'][chname]['topicSetBy'] = parts[3]
elif category == 'private:topic:ts':
out['channels'][chname]['topicSetAt'] = to_unixnano(parts[3])
elif category == 'private:mlockext':
# the channel forward mode is +L on insp/unreal, +f on charybdis
# charybdis has a +L ("large banlist") taking no argument
# and unreal has a +f ("flood limit") taking two colon-delimited numbers,
# so check for an argument that starts with a #
if parts[3].startswith('L#') or parts[3].startswith('f#'):
out['channels'][chname]['forward'] = parts[3][1:]
elif category == 'CA':
# channel access lists
# CA #mychannel shivaram +AFORafhioqrstv 1600134478 shivaram
chname, username, flags, set_at = parts[1], parts[2], parts[3], int(parts[4])
chname = parts[1]
chdata = out['channels'][chname]
flags = parts[3]
set_at = int(parts[4])
if 'amode' not in chdata:
chdata['amode'] = {}
# see libathemecore/flags.c: +o is op, +O is autoop, etc.
if 'F' in flags:
# If the username starts with "!", it's actually a GroupServ group.
if username.startswith('!'):
group_founders = group_to_founders.get(username)
if not group_founders:
# skip this and warn about it later
continue
# attempt to promote the first group founder to channel founder
username = group_founders[0]
# but everyone gets the +q flag
for founder in group_founders:
chdata['amode'][founder] = 'q'
# there can only be one founder
preexisting_founder, preexisting_set_at = channel_to_founder[chname]
if preexisting_founder is None or set_at < preexisting_set_at:
chdata['founder'] = username
channel_to_founder[chname] = (username, set_at)
# but multiple people can receive the 'q' amode
chdata['amode'][username] = 'q'
continue
if MASK_MAGIC_REGEX.search(username):
# ignore groups, masks, etc. for any field other than founder
continue
# record the first appearing successor, if necessary
if 'S' in flags:
if not chdata.get('successor'):
chdata['successor'] = username
# finally, handle amodes
if 'q' in flags:
chdata['amode'][username] = 'q'
elif 'a' in flags:
chdata['amode'][username] = 'a'
elif 'o' in flags or 'O' in flags:
chdata['amode'][username] = 'o'
elif 'h' in flags or 'H' in flags:
chdata['amode'][username] = 'h'
elif 'v' in flags or 'V' in flags:
chdata['amode'][username] = 'v'
else:
pass
# do some basic integrity checks
def validate_user(name):
if not name:
return False
return bool(out['users'].get(name))
invalid_channels = []
for chname, chdata in out['channels'].items():
if not validate_user(chdata.get('founder')):
if validate_user(chdata.get('successor')):
chdata['founder'] = chdata['successor']
else:
invalid_channels.append(chname)
for chname in invalid_channels:
logging.warning("Unable to find a valid founder for channel %s, discarding it", chname)
del out['channels'][chname]
return out
def main():
if len(sys.argv) != 3:
raise Exception("Usage: atheme2json.py atheme_db output.json")
with open(sys.argv[1], 'rb') as infile:
output = convert(infile)
with open(sys.argv[2], 'w') as outfile:
json.dump(output, outfile)
if __name__ == '__main__':
logging.basicConfig()
sys.exit(main())

View File

@ -1,29 +0,0 @@
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.

View File

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

View File

@ -1,113 +0,0 @@
# Ergo Docker
This folder holds Ergo's Docker compose file. The Dockerfile is in the root
directory. Ergo is published automatically to the GitHub Container Registry at
[ghcr.io/ergochat/ergo](https://ghcr.io/ergochat/ergo).
Most users should use either the `stable` tag (corresponding to the
`stable` branch in git, which tracks the latest stable release), or
a tag corresponding to a tagged version (e.g. `v2.8.0`). The `master`
tag corresponds to the `master` branch, which is not recommended for
production use. The `latest` tag is not recommended.
## Quick start
The Ergo docker image is designed to work out of the box - it comes with a
usable default config and will automatically generate self-signed TLS
certificates. To get a working ircd, all you need to do is run the image and
expose the ports:
```shell
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).
The first time Ergo runs it will create a config file with a randomised
oper password. This is output to stdout, and you can view it with the docker
logs command:
```shell
# Assuming your container is named `ergo`; use `docker container ls` to
# find the name if you're not sure.
docker logs ergo
```
You should see a line similar to:
```
Oper username:password is admin:cnn2tm9TP3GeI4vLaEMS
```
We recommend the use of `--init` (`init: true` in docker-compose) to solve an
edge case involving unreaped zombie processes when Ergo's script API is used
for authentication or IP validation. For more details, see
[krallin/tini#8](https://github.com/krallin/tini/issues/8).
## Persisting data
Ergo has a persistent data store, used to keep account details, channel
registrations, and so on. To persist this data across restarts, you can mount
a volume at /ircd.
For example, to create a new docker volume and then mount it:
```shell
docker volume create ergo-data
docker run --init --name ergo -d -v ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
```
Or to mount a folder from your host machine:
```shell
mkdir ergo-data
docker run --init --name ergo -d -v $(pwd)/ergo-data:/ircd -p 6667:6667 -p 6697:6697 ghcr.io/ergochat/ergo:stable
```
## Customising the config
Ergo's config file is stored at /ircd/ircd.yaml. If the file does not
exist, the default config will be written out. You can copy the config from
the container, edit it, and then copy it back:
```shell
# Assuming that your container is named `ergo`, as above.
docker cp ergo:/ircd/ircd.yaml .
vim ircd.yaml # edit the config to your liking
docker cp ircd.yaml ergo:/ircd/ircd.yaml
```
You can use the `/rehash` command to make Ergo reload its config, or
send it the HUP signal:
```shell
docker kill -s SIGHUP ergo
```
## Using custom TLS certificates
TLS certs will by default be read from /ircd/fullchain.pem, with a private key
in /ircd/privkey.pem. You can customise this path in the ircd.yaml file if
you wish to mount the certificates from another volume. For information
on using Let's Encrypt certificates, see
[this manual entry](https://github.com/ergochat/ergo/blob/master/docs/MANUAL.md#using-valid-tls-certificates).
## Using docker-compose
This folder contains a sample docker-compose file which can be used
to start an Ergo instance with ports exposed and data persisted in
a docker volume. Simply download the file and then bring it up:
```shell
curl -O https://raw.githubusercontent.com/ergochat/ergo/master/distrib/docker/docker-compose.yml
docker-compose up -d
```
## Building
If you wish to manually build the docker image, you need to do so from
the root of the Ergo repository (not the `distrib/docker` directory):
```shell
docker build .
```

View File

@ -1,21 +0,0 @@
version: "3.8"
services:
ergo:
init: true
image: ghcr.io/ergochat/ergo:stable
ports:
- "6667:6667/tcp"
- "6697:6697/tcp"
volumes:
- data:/ircd
deploy:
placement:
constraints:
- "node.role == manager"
restart_policy:
condition: on-failure
replicas: 1
volumes:
data:

View File

@ -1,26 +0,0 @@
#!/bin/sh
# make config file
if [ ! -f "/ircd/ircd.yaml" ]; then
awk '{gsub(/path: languages/,"path: /ircd-bin/languages")}1' /ircd-bin/default.yaml > /tmp/ircd.yaml
# change default oper passwd
OPERPASS=$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c20)
echo "Oper username:password is admin:$OPERPASS"
ENCRYPTEDPASS=$(echo "$OPERPASS" | /ircd-bin/ergo genpasswd)
ORIGINALPASS='\$2a\$04\$0123456789abcdef0123456789abcdef0123456789abcdef01234'
awk "{gsub(/password: \\\"$ORIGINALPASS\\\"/,\"password: \\\"$ENCRYPTEDPASS\\\"\")}1" /tmp/ircd.yaml > /tmp/ircd2.yaml
unset OPERPASS
unset ENCRYPTEDPASS
unset ORIGINALPASS
mv /tmp/ircd2.yaml /ircd/ircd.yaml
fi
# make self-signed certs if they don't already exist
/ircd-bin/ergo mkcerts
# run!
exec /ircd-bin/ergo run

View File

@ -1,57 +0,0 @@
#!/bin/sh
# Init script for the ergo IRCd
# Created 14/06/2021 by georg@lysergic.dev
# Desgigned for and tested on Slackware -current
# Depends on `daemon` (installable using slackpkg)
# In its stock configuration ergo will be jailed to /opt/ergo - all paths are relative from there. Consider this in your ergo configuration file (i.e. certificate, database and log locations)
NAME=ergo
DIR=/opt/ergo
ERGO=/ergo
DAEMONIZER=/usr/bin/daemon
CONFIG=ircd.yaml
USER=ergo
GROUP=ergo
daemon_start() {
$DAEMONIZER -n $NAME -v -- chroot --userspec=$USER --groups=$USER -- $DIR $ERGO run --conf $CONFIG
}
daemon_stop() {
$DAEMONIZER --stop -n $NAME -v
}
daemon_restart() {
$DAEMONIZER --restart -n $NAME -v
}
daemon_reload() {
$DAEMONIZER --signal=SIGHUP -n $NAME -v
}
daemon_status() {
$DAEMONIZER --running -n $NAME -v
}
case "$1" in
start)
daemon_start
;;
stop)
daemon_stop
;;
restart)
daemon_restart
;;
reload)
daemon_reload
;;
status)
daemon_status
;;
*)
echo "Source: https://github.com/ergochat/ergo"
echo "Usage: $0 {start|stop|restart|reload|status}"
exit 1
esac

View File

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

View File

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

View File

@ -1,8 +0,0 @@
This directory contains s6 srv and log services for ergo.
These services expect that ergo is installed to /opt/ergo,
and an ergo system user that owns /opt/ergo.
To install:
cp -r ergo-srv ergo-log /etc/s6/sv/
cp ergo.conf /etc/s6/config/

View File

@ -1 +0,0 @@
ergo-srv

View File

@ -1 +0,0 @@
3

View File

@ -1 +0,0 @@
ergo

View File

@ -1,9 +0,0 @@
#!/usr/bin/execlineb -P
envfile /etc/s6/config/ergo.conf
importas -sCiu DIRECTIVES DIRECTIVES
ifelse { test -w /var/log } {
foreground { install -d -o s6log -g s6log /var/log/ergo }
s6-setuidgid s6log exec -c s6-log -d3 -b -- ${DIRECTIVES} /var/log/ergo
}
foreground { install -d -o s6log -g s6log /run/log/ergo }
s6-setuidgid s6log exec -c s6-log -d3 -b -- ${DIRECTIVES} /run/log/ergo

View File

@ -1 +0,0 @@
longrun

View File

@ -1 +0,0 @@
ergo-log

View File

@ -1,4 +0,0 @@
#!/usr/bin/execlineb -P
fdmove -c 2 1
execline-cd /opt/ergo
s6-setuidgid ergo ./ergo run

View File

@ -1 +0,0 @@
longrun

View File

@ -1,2 +0,0 @@
# This configures the directives used for s6-log in the log service.
DIRECTIVES="n3 s2000000"

View File

@ -1,23 +0,0 @@
[Unit]
Description=ergo
After=network.target
# If you are using MySQL for history storage, comment out the above line
# and uncomment these two instead (you must independently install and configure
# MySQL for your system):
# Wants=mysql.service
# After=network.target mysql.service
[Service]
Type=notify
User=ergo
WorkingDirectory=/home/ergo
ExecStart=/home/ergo/ergo run --conf /home/ergo/ircd.yaml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
LimitNOFILE=1048576
NotifyAccess=main
# Uncomment this for a hidden service:
# PrivateNetwork=true
[Install]
WantedBy=multi-user.target

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,60 +0,0 @@
# MOTD Formatting Codes
If `motd-formatting` is enabled in the config file, you can use special escape codes to
easily get bold, coloured, italic, and other types of specially-formatted text.
Our formatting character is '$', and this followed by specific letters means that the text
after it is formatted in the given way. Here are the character pairs and what they output:
--------------------------
Escape | Output
--------------------------
$$ | Dollar sign ($)
$b | Bold
$c | Color code
$i | Italics
$u | Underscore
$r | Reset
--------------------------
## Color codes
After the color code (`$c`), you can use square brackets to specify which foreground and
background colors to output. For example:
This line outputs red text:
`This is $c[red]really cool text!`
This line outputs red text with a light blue background:
`This is $c[red,light blue]22% cooler!`
If you're familiar with IRC colors you can also use the raw numbers you're used to:
`This is $c13pink text`
Here are the color names we support, and which IRC colors they map to:
--------------------
Code | Name
--------------------
00 | white
01 | black
02 | blue
03 | green
04 | red
05 | brown
06 | magenta
07 | orange
08 | yellow
09 | light green
10 | cyan
11 | light cyan
12 | light blue
13 | pink
14 | grey
15 | light grey
--------------------
In addition, some newer clients can make use of the colour codes 16-98, though they don't
have any names assigned. Take a look at this table to see which colours these numbers are:
https://modern.ircdocs.horse/formatting.html#colors-16-98

36
docs/README Normal file
View File

@ -0,0 +1,36 @@
▄▄▄ ▄▄▄· ▄▄ • ▐ ▄
▪ ▀▄ █·▐█ ▀█ ▐█ ▀ ▪▪ •█▌▐█▪
▄█▀▄ ▐▀▀▄ ▄█▀▀█ ▄█ ▀█▄ ▄█▀▄▪▐█▐▐▌ ▄█▀▄
▐█▌.▐▌▐█•█▌▐█ ▪▐▌▐█▄▪▐█▐█▌ ▐▌██▐█▌▐█▌.▐▌
▀█▄▀▪.▀ ▀ ▀ ▀ ·▀▀▀▀ ▀█▄▀ ▀▀ █▪ ▀█▄▀▪
-----------------------------------------------------------------------------------------------
Oragono is a modern, experimental IRC server written in Go. It's designed to be simple to setup
and use, and to provide the majority of features that IRC users expect today.
It includes features such as UTF-8 nicks and channel names, client accounts and SASL, and other
assorted IRCv3 support.
http://oragono.io/
https://github.com/DanielOaks/oragono
-----------------------------------------------------------------------------------------------
=== Installing ===
Copy the example config file to ircd.yaml with a command like:
$ cp oragono.yaml ircd.yaml
Modify the config file as you like.
Run these commands in order -- these will setup each section of the server:
$ oragono initdb
$ oragono mkcerts
$ oragono run
And you should now be running Oragono!

View File

@ -1,128 +0,0 @@
__ __ ______ ___ ______ ___
__/ // /_/ ____/ __ \/ ____/ __ \
/_ // __/ __/ / /_/ / / __/ / / /
/_ // __/ /___/ _, _/ /_/ / /_/ /
/_//_/ /_____/_/ |_|\____/\____/
Ergo IRCd User Guide
https://ergo.chat/
_Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn@cs.stanford.edu>_
--------------------------------------------------------------------------------------------
Table of Contents
- [Introduction](#introduction)
- [About IRC](#about-irc)
- [How Ergo is different](#how-ergo-is-different)
- [Account registration](#account-registration)
- [Channel registration](#channel-registration)
- [Always-on](#always-on)
- [Multiclient](#multiclient)
- [History](#history)
- [Push notifications](#push-notifications)
--------------------------------------------------------------------------------------------
# Introduction
Welcome to Ergo, a modern IRC server!
This guide is for end users of Ergo (people using Ergo to chat). If you're installing your own Ergo instance, you should consult the official manual instead (a copy should be bundled with your release, in the `docs/` directory).
This guide assumes that Ergo is in its default or recommended configuration; Ergo server administrators can change settings to make the server behave differently. If something isn't working as expected, ask your server administrator for help.
# About IRC
Before continuing, you should be familiar with basic features of the IRC platform. If you're comfortable with IRC, you can skip this section.
[IRC](https://en.wikipedia.org/wiki/Internet_Relay_Chat) is a chat platform invented in 1988, which makes it older than the World Wide Web! At its most basic level, IRC is a chat system composed of chat rooms; these are called "channels" and their names begin with a `#` character (this is actually the origin of the [hashtag](https://www.cmu.edu/homepage/computing/2014/summer/originstory.shtml)!). As a user, you "join" the channels you're interested in, enabling you to participate in those discussions.
Here are some guides covering the basics of IRC:
* [Fedora Magazine: Beginner's Guide to IRC](https://fedoramagazine.org/beginners-guide-irc/)
* [IRCHelp's IRC Tutorial](https://www.irchelp.org/faq/irctutorial.html) (in particular, section 3, "Beyond the Basics")
# How Ergo is different
Ergo differs in many ways from conventional IRC servers. If you're *not* familiar with other IRC servers, you may want to skip this section. Here are some of the most salient differences:
* Ergo integrates a "bouncer" into the server. In particular:
* Ergo stores message history for later retrieval.
* You can be "present" on the server (joined to channels, able to receive DMs) without having an active client connection to the server.
* Conversely, you can use multiple clients to view / control the same presence (nickname) on the server, as long as you authenticate with SASL when connecting.
* Ergo integrates "services" into the server. In particular:
* Nicknames are strictly reserved: once you've registered your nickname, you must log in in order to use it. Consequently, SASL is more important when using Ergo than in other systems.
* All properties of registered channels are protected without the need for `ChanServ` to be joined to the channel.
* Ergo "cloaks", i.e., cryptographically scrambles, end user IPs so that they are not displayed publicly.
* By default, the user/ident field is inoperative in Ergo: it is always set to `~u`, regardless of the `USER` command or the client's support for identd. This is because it is not in general a reliable or trustworthy way to distinguish users coming from the same IP. Ergo's integrated bouncer features should reduce the need for shared shell hosts and hosted bouncers (one of the main remaining use cases for identd).
* By default, Ergo is only accessible via TLS.
# Account registration
Although (as in other IRC systems) basic chat functionality is available without creating an account, most of Ergo's features require an account. You can create an account by sending a direct message to `NickServ`. (In IRC jargon, `NickServ` is a "network service", but if you're not familiar with the concept you can just think of it as a bot or a text user interface.) In a typical client, this will be:
```
/msg NickServ register mySecretPassword validEmailAddress@example.com
```
This registers your current nickname as your account name, with the password `mySecretPassword` (replace this with your own secret password!)
Once you have registered your account, you must configure SASL in your client, so that you will be logged in automatically on each connection. [libera.chat's SASL guide](https://libera.chat/guides/sasl) covers most popular clients.
If your client doesn't support SASL, you can typically use the "server password" (`PASS`) field in your client to log into your account automatically when connecting. Set the server password to `accountname:accountpassword`, where `accountname` is your account name and `accountpassword` is your account password.
For information on how to use a client certificate for authentication, see the [operator manual](https://github.com/ergochat/ergo/blob/stable/docs/MANUAL.md#client-certificates).
# Channel registration
Once you've registered your nickname, you can use it to register channels. By default, channels are ephemeral; they go away when there are no longer any users in the channel, or when the server is restarted. Registering a channel gives you permanent control over it, and ensures that its settings will persist. To register a channel, send a message to `ChanServ`:
```
/msg ChanServ register #myChannel
```
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
By default, if you lose your connection to the IRC server, you are no longer present on the server; other users will see that you have "quit", you will no longer appear in channel lists, and you will not be able to receive direct messages. Ergo supports "always-on clients", where you remain on the server even when you are disconnected. To enable this, you can send a message to `NickServ`:
```
/msg NickServ set always-on true
```
# Multiclient
Ergo natively supports attaching multiple clients to the same nickname (this normally requires the use of an external bouncer, like ZNC or WeeChat's "relay" functionality). To use this feature, simply authenticate with SASL (or the PASS workaround, if necessary) when connecting. In the recommended configuration of Ergo, you will receive the nickname associated with your account, even if you have other clients already using it.
# History
Ergo stores message history on the server side (typically not an unlimited amount --- consult your server's FAQ, or your server administrator, to find out how much is being stored and how long it's being retained).
1. The [IRCv3 chathistory specification](https://ircv3.net/specs/extensions/chathistory) offers the most fine-grained control over history replay. It is supported by [Gamja](https://git.sr.ht/~emersion/gamja), [Goguma](https://sr.ht/~emersion/goguma/), and [Kiwi IRC](https://github.com/kiwiirc/kiwiirc), and hopefully other clients soon.
1. We emulate the [ZNC playback module](https://wiki.znc.in/Playback) for clients that support it. You may need to enable support for it explicitly in your client. For example, in [Textual](https://www.codeux.com/textual/), go to "Server properties", select "Vendor specific", uncheck "Do not automatically join channels on connect", and check "Only play back messages you missed". ZNC's wiki page covers other common clients (although if the feature is only supported via a script or third-party extension, the following option may be easier).
1. If you set your client to always-on (see the previous section for details), you can set a "device ID" for each device you use. Ergo will then remember the last time your device was present on the server, and each time you sign on, it will attempt to replay exactly those messages you missed. There are a few ways to set your device ID when connecting:
- You can add it to your SASL username with an `@`, e.g., if your SASL username is `alice` you can send `alice@phone`
- You can add it in a similar way to your IRC protocol username ("ident"), e.g., `alice@phone`
- If login to user accounts via the `PASS` command is enabled on the server, you can provide it there, e.g., by sending `alice@phone:hunter2` as the server password
1. If you only have one device, you can set your client to be always-on and furthermore `/msg NickServ set autoreplay-missed true`. This will replay missed messages, with the caveat that you must be connecting with at most one client at a time.
1. You can manually request history using `/history #channel 1h` (the parameter is either a message count or a time duration). (Depending on your client, you may need to use `/QUOTE history` instead.)
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`.
# Private channels
If you have registered a channel, you can make it private. The best way to do this is with the `+i` ("invite-only") mode:
1. Set your channel to be invite-only (`/mode #example +i`)
1. Identify the users you want to be able to access the channel. Ensure that they have registered their accounts (you should be able to see their registration status if you `/WHOIS` their nicknames).
1. Add the desired nick/account names to the invite exception list (`/mode #example +I alice`) or give them persistent voice (`/msg ChanServ AMODE #example +v alice`)
1. If you want to grant a persistent channel privilege to a user, you can do it with `CS AMODE` (`/msg ChanServ AMODE #example +o bob`)
# Push notifications
Ergo has experimental support for mobile push notifications. The server operator must enable this functionality; to check whether this is the case, you can send `/msg NickServ push list`. You must additionally be using a client (e.g. Goguma) that supports the functionality, and your account must be set to always-on (`/msg NickServ set always-on true`, as described above).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -1,2 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="552.48" height="226.39" version="1.1" viewBox="0 0 146.18 59.901" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(3.0169 0 0 3.0169 -99.412 -462.64)"><g stroke-width=".40656" aria-label="#ERGO"><path d="m34.33 165.07h1.9027l2.0003-11.351h-1.9027l-0.55292 3.1549h-2.0328v1.7888h1.7075l-0.24394 1.4636h-2.0816v1.7888h1.7563zm3.1549 0h1.9027l0.55292-3.1549h2.0328v-1.7888h-1.7075l0.24393-1.4636h2.0816v-1.7888h-1.7563l0.55292-3.1549h-1.9027z" fill="#5f901d"/><g fill="#161616"><path d="m51.898 165.07v-2.0003h-4.8136v-2.7483h4.651v-2.0003h-4.651v-2.602h4.8136v-2.0003h-7.253v11.351z"/><path d="m56.001 160.86h1.1221l1.9027 4.2119h2.667l-2.2279-4.5372c1.2685-0.35777 2.0003-1.61 2.0003-3.2037 0-2.1954-1.2522-3.6102-3.4801-3.6102h-4.3908v11.351h2.4068zm0-1.8864v-3.285h1.3986c1.1384 0 1.5287 0.40656 1.5287 1.3986v0.48787c0 0.992-0.3903 1.3986-1.5287 1.3986z"/><path d="m68.823 165.07h2.1791v-6.0658h-4.1144v1.7238h1.9352v0.82938c0 1.0083-0.55292 1.7401-1.6425 1.7401-1.4148 0-1.8864-1.2034-1.8864-3.041v-1.8539c0-1.8214 0.45534-2.911 1.6913-2.911 1.1221 0 1.4961 0.89443 1.7401 1.8539l2.2767-0.55291c-0.45534-1.9515-1.6262-3.2687-3.968-3.2687-2.9435 0-4.3258 2.1141-4.3258 5.9683 0 3.6102 1.2034 5.7731 3.4313 5.7731 1.3986 0 2.1629-0.8619 2.5369-1.8051h0.14636z"/><path d="m76.791 165.27c3.0411 0 4.4396-2.1629 4.4396-5.8707 0-3.7078-1.3986-5.8707-4.4396-5.8707-3.041 0-4.4396 2.1629-4.4396 5.8707 0 3.7078 1.3986 5.8707 4.4396 5.8707zm0-1.9677c-1.3823 0-1.8376-1.0896-1.8376-2.911v-1.984c0-1.8214 0.45534-2.911 1.8376-2.911 1.3823 0 1.8376 1.0896 1.8376 2.911v1.9677c0 1.8376-0.45534 2.9272-1.8376 2.9272z"/></g></g><g fill="#4a7411" stroke-width=".17823" aria-label="irc server"><path d="m42.203 168.4c0.24239 0 0.34932-0.12833 0.34932-0.32081v-0.0927c0-0.19249-0.10694-0.32081-0.34932-0.32081s-0.34933 0.12832-0.34933 0.32081v0.0927c0 0.19248 0.10694 0.32081 0.34933 0.32081zm-0.28516 4.5412h0.57033v-3.6786h-0.57033z"/><path d="m44.271 172.94v-2.4952c0-0.34933 0.37071-0.61311 0.98382-0.61311h0.33507v-0.57032h-0.221c-0.59884 0-0.93391 0.32793-1.0622 0.67726h-0.03565v-0.67726h-0.57033v3.6786z"/><path d="m47.65 173.03c0.69865 0 1.1763-0.3422 1.4116-0.86975l-0.41349-0.27804c-0.19961 0.42062-0.53468 0.64162-0.99807 0.64162-0.67726 0-1.0266-0.46339-1.0266-1.105v-0.62736c0-0.64162 0.34932-1.105 1.0266-1.105 0.44913 0 0.76281 0.221 0.89826 0.59885l0.47765-0.24239c-0.21387-0.50617-0.64875-0.86262-1.3759-0.86262-1.0337 0-1.6397 0.74855-1.6397 1.9248s0.60597 1.9249 1.6397 1.9249z"/><path d="m52.655 173.03c0.84123 0 1.3617-0.43488 1.3617-1.1478 0-0.55607-0.31368-0.91252-1.1264-1.0337l-0.28516-0.0428c-0.45626-0.0713-0.70578-0.21387-0.70578-0.57033 0-0.34932 0.24952-0.57032 0.72004-0.57032 0.47052 0 0.7842 0.221 0.94817 0.44913l0.37784-0.3422c-0.29942-0.37071-0.69152-0.59171-1.2832-0.59171-0.74855 0-1.3118 0.35645-1.3118 1.0836 0 0.68439 0.50616 0.96243 1.1834 1.0622l0.29229 0.0428c0.48478 0.0713 0.64162 0.29229 0.64162 0.57745 0 0.37785-0.28516 0.59885-0.76994 0.59885-0.46339 0-0.80559-0.20675-1.0908-0.5632l-0.40636 0.32794c0.32794 0.43487 0.77707 0.72004 1.4543 0.72004z"/><path d="m56.405 173.03c0.69152 0 1.2191-0.3422 1.4543-0.84124l-0.40636-0.29229c-0.19248 0.40636-0.54894 0.63449-1.0123 0.63449-0.68439 0-1.0908-0.47765-1.0908-1.1121v-0.1711h2.6449v-0.2709c0-1.0408-0.60597-1.7965-1.5898-1.7965-0.99807 0-1.6539 0.75568-1.6539 1.9248s0.65588 1.9249 1.6539 1.9249zm0-3.3721c0.58459 0 0.97669 0.43487 0.97669 1.0836v0.0784h-2.0318v-0.0499c0-0.64161 0.43487-1.1121 1.0551-1.1121z"/><path d="m59.506 172.94v-2.4952c0-0.34933 0.37071-0.61311 0.98381-0.61311h0.33507v-0.57032h-0.221c-0.59884 0-0.93391 0.32793-1.0622 0.67726h-0.03565v-0.67726h-0.57033v3.6786z"/><path d="m63.099 172.94 1.2975-3.6786h-0.54894l-0.65588 1.825-0.39923 1.2547h-0.03564l-0.39923-1.2547-0.64162-1.825h-0.57033l1.2904 3.6786z"/><path d="m66.457 173.03c0.69152 0 1.2191-0.3422 1.4543-0.84124l-0.40636-0.29229c-0.19249 0.40636-0.54894 0.63449-1.0123 0.63449-0.68439 0-1.0908-0.47765-1.0908-1.1121v-0.1711h2.6449v-0.2709c0-1.0408-0.60597-1.7965-1.5898-1.7965-0.99807 0-1.6539 0.75568-1.6539 1.9248s0.65588 1.9249 1.6539 1.9249zm0-3.3721c0.58458 0 0.97668 0.43487 0.97668 1.0836v0.0784h-2.0318v-0.0499c0-0.64161 0.43488-1.1121 1.0551-1.1121z"/><path d="m69.558 172.94v-2.4952c0-0.34933 0.37071-0.61311 0.98382-0.61311h0.33507v-0.57032h-0.221c-0.59884 0-0.93391 0.32793-1.0622 0.67726h-0.03565v-0.67726h-0.57033v3.6786z"/></g></g></svg>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0" y="0" width="800" height="200" viewBox="0, 0, 800, 200">
<g id="Layer_1">
<g>
<path d="M86.334,81.496 L86.334,100.351 L66.729,100.351 L66.729,118.504 L56.935,118.504 L56.935,152.773 L66.729,152.773 L66.729,171.627 L86.334,171.627 L86.334,189.78 L47.125,189.78 L47.125,171.627 L27.521,171.627 L27.521,154.142 L17.727,154.142 L17.727,117.135 L27.521,117.135 L27.521,100.351 L47.125,100.351 L47.125,81.496 L86.334,81.496 z" fill="#000000"/>
<path d="M537.229,81.496 L537.229,100.351 L517.625,100.351 L517.625,118.504 L507.831,118.504 L507.831,152.773 L517.625,152.773 L517.625,171.627 L537.229,171.627 L537.229,189.78 L498.021,189.78 L498.021,171.627 L478.417,171.627 L478.417,154.142 L468.623,154.142 L468.623,117.135 L478.417,117.135 L478.417,100.351 L498.021,100.351 L498.021,81.496 L537.229,81.496 z" fill="#000000"/>
<path d="M615.646,10.22 L615.646,45.858 L625.456,45.858 L625.456,81.496 L635.25,81.496 L635.25,117.135 L645.06,117.135 L645.06,47.227 L635.25,47.227 L635.25,29.075 L654.854,29.075 L654.854,45.858 L674.458,45.858 L674.458,82.865 L664.664,82.865 L664.664,154.142 L654.854,154.142 L654.854,189.78 L635.25,189.78 L635.25,154.142 L625.456,154.142 L625.456,82.865 L615.646,82.865 L615.646,171.627 L576.437,171.627 L576.437,117.135 L586.247,117.135 L586.247,81.496 L596.041,81.496 L596.041,45.858 L605.852,45.858 L605.852,10.22 L615.646,10.22 z" fill="#000000"/>
<path d="M752.875,81.496 L752.875,100.351 L733.271,100.351 L733.271,118.504 L723.477,118.504 L723.477,152.773 L733.271,152.773 L733.271,171.627 L752.875,171.627 L752.875,189.78 L713.667,189.78 L713.667,171.627 L694.063,171.627 L694.063,154.142 L684.268,154.142 L684.268,117.135 L694.063,117.135 L694.063,100.351 L713.667,100.351 L713.667,81.496 L752.875,81.496 z" fill="#000000"/>
<path d="M135.639,176.125 Q136.532,176.126 137.17,176.756 Q137.808,177.386 137.808,178.279 Q137.808,179.204 137.162,179.826 Q136.516,180.448 135.639,180.448 L135.065,180.448 Q134.172,180.448 133.534,179.818 Q132.895,179.188 132.895,178.295 Q132.895,177.37 133.541,176.748 Q134.188,176.126 135.065,176.126 L135.639,176.125 z" fill="#000000"/>
<path d="M668.7,165.773 L668.7,173.86 L660.613,173.86 L660.613,165.773 L668.7,165.773 z" fill="#000000"/>
<path d="M119.783,165.773 L119.783,173.86 L111.696,173.86 L111.696,165.773 L119.783,165.773 z" fill="#000000"/>
<path d="M786.325,165.773 L786.325,173.86 L778.237,173.86 L778.237,165.773 L786.325,165.773 z" fill="#000000"/>
<path d="M772.479,100.351 L772.479,117.135 L782.289,117.135 L782.289,154.142 L772.479,154.142 L772.479,171.627 L752.875,171.627 L752.875,152.773 L762.685,152.773 L762.685,118.504 L752.875,118.504 L752.875,100.351 L772.479,100.351 z" fill="#000000"/>
<path d="M556.833,100.351 L556.833,117.135 L566.643,117.135 L566.643,154.142 L556.833,154.142 L556.833,171.627 L537.229,171.627 L537.229,152.773 L547.039,152.773 L547.039,118.504 L537.229,118.504 L537.229,100.351 L556.833,100.351 z" fill="#000000"/>
<path d="M400,29.075 L400,45.858 L419.604,45.858 L419.604,64.713 L400,64.713 L400,47.227 L380.396,47.227 L380.396,135.989 L400,135.989 L400,152.773 L429.414,152.773 L429.414,118.504 L419.604,118.504 L419.604,100.351 L400,100.351 L400,81.496 L439.208,81.496 L439.208,100.351 L458.813,100.351 L458.813,154.142 L439.208,154.142 L439.208,171.627 L360.792,171.627 L360.792,154.142 L350.998,154.142 L350.998,118.504 L341.187,118.504 L341.187,100.351 L360.792,100.351 L360.792,82.865 L350.998,82.865 L350.998,45.858 L360.792,45.858 L360.792,29.075 L400,29.075 z" fill="#000000"/>
<path d="M184.354,29.075 L184.354,45.858 L203.958,45.858 L203.958,82.865 L184.354,82.865 L184.354,100.351 L145.146,100.351 L145.146,117.135 L164.75,117.135 L164.75,171.627 L145.146,171.627 L145.146,154.142 L135.352,154.142 L135.352,81.496 L145.146,81.496 L145.146,64.713 L164.75,64.713 L164.75,81.496 L184.354,81.496 L184.354,47.227 L145.146,47.227 L145.146,64.713 L125.542,64.713 L125.542,29.075 L184.354,29.075 z" fill="#000000"/>
<path d="M105.938,100.351 L105.938,117.135 L115.748,117.135 L115.748,154.142 L105.938,154.142 L105.938,171.627 L86.334,171.627 L86.334,152.773 L96.144,152.773 L96.144,118.504 L86.334,118.504 L86.334,100.351 L105.938,100.351 z" fill="#000000"/>
<path d="M203.958,100.351 L203.958,117.135 L213.768,117.135 L213.768,152.773 L223.563,152.773 L223.563,171.627 L203.958,171.627 L203.958,154.142 L184.354,154.142 L184.354,100.351 L203.958,100.351 z" fill="#000000"/>
<path d="M301.979,29.075 L301.979,45.858 L321.583,45.858 L321.583,117.135 L331.393,117.135 L331.393,154.142 L321.583,154.142 L321.583,171.627 L301.979,171.627 L301.979,152.773 L311.789,152.773 L311.789,118.504 L301.979,118.504 L301.979,100.351 L262.771,100.351 L262.771,171.627 L243.167,171.627 L243.167,154.142 L233.373,154.142 L233.373,118.504 L223.563,118.504 L223.563,100.351 L243.167,100.351 L243.167,82.865 L233.373,82.865 L233.373,45.858 L243.167,45.858 L243.167,29.075 L301.979,29.075 z M282.375,47.227 L262.771,47.227 L262.771,81.496 L301.979,81.496 L301.979,64.713 L282.375,64.713 L282.375,47.227 z" fill="#000000"/>
<path d="M354.124,167.432 Q354.794,167.432 355.273,167.911 Q355.751,168.389 355.751,169.075 Q355.751,169.745 355.273,170.224 Q354.794,170.702 354.124,170.702 Q353.438,170.702 352.96,170.224 Q352.481,169.745 352.481,169.075 Q352.481,168.389 352.96,167.911 Q353.438,167.432 354.124,167.432 z" fill="#000000"/>
<path d="M76.826,140.487 Q77.72,140.487 78.358,141.118 Q78.996,141.748 78.996,142.641 Q78.996,143.566 78.35,144.188 Q77.704,144.81 76.827,144.81 L76.252,144.81 Q75.359,144.81 74.721,144.18 Q74.083,143.55 74.083,142.657 Q74.083,141.732 74.729,141.11 Q75.375,140.487 76.252,140.487 L76.826,140.487 z" fill="#000000"/>
<path d="M743.368,140.487 Q744.261,140.487 744.899,141.118 Q745.537,141.748 745.537,142.641 Q745.537,143.566 744.891,144.188 Q744.245,144.81 743.368,144.81 L742.793,144.81 Q741.901,144.81 741.262,144.18 Q740.624,143.55 740.624,142.657 Q740.624,141.732 741.27,141.11 Q741.916,140.487 742.794,140.487 L743.368,140.487 z" fill="#000000"/>
<path d="M296.221,130.135 L296.221,138.222 L288.134,138.222 L288.134,130.135 L296.221,130.135 z" fill="#000000"/>
<path d="M413.846,130.135 L413.846,138.222 L405.758,138.222 L405.758,130.135 L413.846,130.135 z" fill="#000000"/>
<path d="M174.544,130.231 Q176.187,130.231 177.344,131.387 Q178.5,132.544 178.5,134.171 Q178.5,135.814 177.336,136.97 Q176.171,138.127 174.544,138.127 Q172.917,138.127 171.761,136.97 Q170.604,135.814 170.604,134.171 Q170.604,132.544 171.761,131.387 Q172.917,130.231 174.544,130.231 z" fill="#000000"/>
<path d="M570.679,94.497 L570.679,102.584 L562.592,102.584 L562.592,94.497 L570.679,94.497 z" fill="#000000"/>
<path d="M453.054,58.859 L453.054,66.946 L444.967,66.946 L444.967,58.859 L453.054,58.859 z" fill="#000000"/>
<path d="M21.763,58.859 L21.763,66.946 L13.675,66.946 L13.675,58.859 L21.763,58.859 z" fill="#000000"/>
<path d="M472.658,58.859 L472.658,66.946 L464.571,66.946 L464.571,58.859 L472.658,58.859 z" fill="#000000"/>
<path d="M688.304,58.859 L688.304,66.946 L680.217,66.946 L680.217,58.859 L688.304,58.859 z" fill="#000000"/>
<path d="M586.232,58.954 Q587.875,58.955 589.031,60.111 Q590.188,61.267 590.188,62.895 Q590.188,64.537 589.023,65.694 Q587.859,66.85 586.232,66.85 Q584.605,66.85 583.448,65.694 Q582.292,64.537 582.292,62.895 Q582.292,61.267 583.448,60.111 Q584.605,58.955 586.232,58.955 z" fill="#000000"/>
<path d="M216.895,60.518 L217.517,60.637 L218.043,60.996 Q218.522,61.475 218.522,62.161 Q218.522,62.831 218.044,63.309 Q217.565,63.788 216.895,63.788 Q216.209,63.788 215.731,63.309 Q215.252,62.831 215.252,62.161 Q215.252,61.475 215.731,60.996 C216.439,60.517 216.051,60.677 216.895,60.518 z" fill="#000000"/>
<path d="M429.398,23.316 Q431.041,23.316 432.198,24.473 Q433.354,25.629 433.354,27.256 Q433.354,28.899 432.19,30.056 Q431.025,31.212 429.398,31.212 Q427.771,31.212 426.615,30.056 Q425.458,28.899 425.458,27.256 Q425.458,25.629 426.615,24.473 Q427.771,23.316 429.398,23.316 z" fill="#000000"/>
<path d="M314.916,24.88 Q315.586,24.88 316.064,25.358 Q316.543,25.837 316.543,26.523 Q316.543,27.193 316.064,27.671 Q315.586,28.15 314.916,28.15 Q314.23,28.15 313.751,27.671 Q313.273,27.193 313.273,26.523 Q313.273,25.837 313.751,25.358 Q314.23,24.88 314.916,24.88 z" fill="#000000"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

208
ergo.go
View File

@ -1,208 +0,0 @@
// Copyright (c) 2012-2014 Jeremy Latt
// Copyright (c) 2014-2015 Edmund Huber
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package main
import (
"bufio"
_ "embed"
"fmt"
"log"
"os"
"strings"
"syscall"
"golang.org/x/crypto/bcrypt"
"golang.org/x/term"
"github.com/docopt/docopt-go"
"github.com/ergochat/ergo/irc"
"github.com/ergochat/ergo/irc/logger"
"github.com/ergochat/ergo/irc/mkcerts"
"github.com/ergochat/ergo/irc/utils"
)
// set via linker flags, either by make or by goreleaser:
var commit = "" // git hash
var version = "" // tagged version
//go:embed default.yaml
var defaultConfig string
// get a password from stdin from the user
func getPasswordFromTerminal() string {
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
log.Fatal("Error reading password:", err.Error())
}
return string(bytePassword)
}
func fileDoesNotExist(file string) bool {
if _, err := os.Stat(file); os.IsNotExist(err) {
return true
}
return false
}
// implements the `ergo mkcerts` command
func doMkcerts(configFile string, quiet bool) {
config, err := irc.LoadRawConfig(configFile)
if err != nil {
log.Fatal(err)
}
if !quiet {
log.Println("making self-signed certificates")
}
certToKey := make(map[string]string)
for name, conf := range config.Server.Listeners {
if conf.TLS.Cert == "" {
continue
}
existingKey, ok := certToKey[conf.TLS.Cert]
if ok {
if existingKey == conf.TLS.Key {
continue
} else {
log.Fatal("Conflicting TLS key files for ", conf.TLS.Cert)
}
}
if !quiet {
log.Printf(" making cert for %s listener\n", name)
}
host := config.Server.Name
cert, key := conf.TLS.Cert, conf.TLS.Key
if !(fileDoesNotExist(cert) && fileDoesNotExist(key)) {
log.Fatalf("Preexisting TLS cert and/or key files: %s %s", cert, key)
}
err := mkcerts.CreateCert("Ergo", host, cert, key)
if err == nil {
if !quiet {
log.Printf(" Certificate created at %s : %s\n", cert, key)
}
certToKey[cert] = key
} else {
log.Fatal(" Could not create certificate:", err.Error())
}
}
}
func main() {
irc.SetVersionString(version, commit)
usage := `ergo.
Usage:
ergo initdb [--conf <filename>] [--quiet]
ergo upgradedb [--conf <filename>] [--quiet]
ergo importdb <database.json> [--conf <filename>] [--quiet]
ergo genpasswd [--conf <filename>] [--quiet]
ergo mkcerts [--conf <filename>] [--quiet]
ergo defaultconfig
ergo gentoken
ergo run [--conf <filename>] [--quiet] [--smoke]
ergo -h | --help
ergo --version
Options:
--conf <filename> Configuration file to use [default: ircd.yaml].
--quiet Don't show startup/shutdown lines.
-h --help Show this screen.
--version Show version.`
arguments, _ := docopt.ParseArgs(usage, nil, irc.Ver)
// don't require a config file for genpasswd
if arguments["genpasswd"].(bool) {
var password string
if term.IsTerminal(int(syscall.Stdin)) {
fmt.Print("Enter Password: ")
password = getPasswordFromTerminal()
fmt.Print("\n")
fmt.Print("Reenter Password: ")
confirm := getPasswordFromTerminal()
fmt.Print("\n")
if confirm != password {
log.Fatal("passwords do not match")
}
} else {
reader := bufio.NewReader(os.Stdin)
text, _ := reader.ReadString('\n')
password = strings.TrimSpace(text)
}
if err := irc.ValidatePassphrase(password); err != nil {
log.Printf("WARNING: this password contains characters that may cause problems with your IRC client software.\n")
log.Printf("We strongly recommend choosing a different password.\n")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
log.Fatal("encoding error:", err.Error())
}
fmt.Println(string(hash))
return
} else if arguments["defaultconfig"].(bool) {
fmt.Print(defaultConfig)
return
} else if arguments["gentoken"].(bool) {
fmt.Println(utils.GenerateSecretKey())
return
} else if arguments["mkcerts"].(bool) {
doMkcerts(arguments["--conf"].(string), arguments["--quiet"].(bool))
return
}
configfile := arguments["--conf"].(string)
config, err := irc.LoadConfig(configfile)
if err != nil {
_, isCertError := err.(*irc.CertKeyError)
if !(isCertError && arguments["mkcerts"].(bool)) {
log.Fatal("Config file did not load successfully: ", err.Error())
}
}
logman, err := logger.NewManager(config.Logging)
if err != nil {
log.Fatal("Logger did not load successfully:", err.Error())
}
if arguments["initdb"].(bool) {
err = irc.InitDB(config.Datastore.Path)
if err != nil {
log.Fatal("Error while initializing db:", err.Error())
}
if !arguments["--quiet"].(bool) {
log.Println("database initialized: ", config.Datastore.Path)
}
} else if arguments["upgradedb"].(bool) {
err = irc.UpgradeDB(config)
if err != nil {
log.Fatal("Error while upgrading db:", err.Error())
}
if !arguments["--quiet"].(bool) {
log.Println("database upgraded: ", config.Datastore.Path)
}
} else if arguments["importdb"].(bool) {
err = irc.ImportDB(config, arguments["<database.json>"].(string))
if err != nil {
log.Fatal("Error while importing db:", err.Error())
}
} else if arguments["run"].(bool) {
if !arguments["--quiet"].(bool) {
logman.Info("server", fmt.Sprintf("%s starting", irc.Ver))
}
// warning if running a non-final version
if strings.Contains(irc.Ver, "unreleased") {
logman.Warning("server", "You are currently running an unreleased beta version of Ergo that may be unstable and could corrupt your database.\nIf you are running a production network, please download the latest build from https://ergo.chat/about and run that instead.")
}
server, err := irc.NewServer(config, logman)
if err != nil {
logman.Error("server", fmt.Sprintf("Could not load server: %s", err.Error()))
os.Exit(1)
}
if !arguments["--smoke"].(bool) {
server.Run()
}
}
}

View File

@ -1,35 +0,0 @@
__ __ ______ ___ ______ ___
__/ // /_/ ____/ __ \/ ____/ __ \
/_ // __/ __/ / /_/ / / __/ / / /
/_ // __/ /___/ _, _/ /_/ / /_/ /
/_//_/ /_____/_/ |_|\____/\____/
This is the default Ergo MOTD.
If motd-formatting is enabled in the config file, you can use the dollarsign character to
create special formatting such as bold, italics and color codes.
For example, here are a few formatted lines (enable motd-formatting to see these in action):
- this is $bbold text$r.
- this is $iitalics text$r.
- this is $c[red]red$c and $c[blue]blue$c text.
- this is $c[red,light blue]red text with a light blue background$c.
- this is a normal escaped dollarsign: $$
And now a few fun colour charts!
$c1,0 00 $c0,1 01 $c0,2 02 $c0,3 03 $c1,4 04 $c0,5 05 $c0,6 06 $c1,7 07
$c1,8 08 $c1,9 09 $c0,10 10 $c1,11 11 $c0,12 12 $c1,13 13 $c1,14 14 $c1,15 15
$c0,16 16 $c0,17 17 $c0,18 18 $c0,19 19 $c0,20 20 $c0,21 21 $c0,22 22 $c0,23 23 $c0,24 24 $c0,25 25 $c0,26 26 $c0,27 27
$c0,28 28 $c0,29 29 $c0,30 30 $c0,31 31 $c0,32 32 $c0,33 33 $c0,34 34 $c0,35 35 $c0,36 36 $c0,37 37 $c0,38 38 $c0,39 39
$c0,40 40 $c0,41 41 $c0,42 42 $c0,43 43 $c0,44 44 $c0,45 45 $c0,46 46 $c0,47 47 $c0,48 48 $c0,49 49 $c0,50 50 $c0,51 51
$c0,52 52 $c0,53 53 $c1,54 54 $c1,55 55 $c1,56 56 $c1,57 57 $c1,58 58 $c0,59 59 $c0,60 60 $c0,61 61 $c0,62 62 $c0,63 63
$c0,64 64 $c1,65 65 $c1,66 66 $c1,67 67 $c1,68 68 $c1,69 69 $c1,70 70 $c1,71 71 $c0,72 72 $c0,73 73 $c0,74 74 $c0,75 75
$c1,76 76 $c1,77 77 $c1,78 78 $c1,79 79 $c1,80 80 $c1,81 81 $c1,82 82 $c1,83 83 $c1,84 84 $c1,85 85 $c1,86 86 $c1,87 87
$c0,88 88 $c0,89 89 $c0,90 90 $c0,91 91 $c0,92 92 $c0,93 93 $c0,94 94 $c0,95 95 $c1,96 96 $c1,97 97 $c1,98 98 $c99,99 99
For more information on using these, see MOTDFORMATTING.md

View File

@ -1,310 +0,0 @@
#!/usr/bin/env python3
"""
Updates the capability definitions at irc/caps/defs.go
To add a capability, add it to the CAPDEFS list below,
then run `make capdefs` from the project root.
"""
import io
import subprocess
import sys
from collections import namedtuple
CapDef = namedtuple("CapDef", ['identifier', 'name', 'url', 'standard'])
CAPDEFS = [
CapDef(
identifier="AccountNotify",
name="account-notify",
url="https://ircv3.net/specs/extensions/account-notify-3.1.html",
standard="IRCv3",
),
CapDef(
identifier="AccountTag",
name="account-tag",
url="https://ircv3.net/specs/extensions/account-tag-3.2.html",
standard="IRCv3",
),
CapDef(
identifier="AwayNotify",
name="away-notify",
url="https://ircv3.net/specs/extensions/away-notify-3.1.html",
standard="IRCv3",
),
CapDef(
identifier="Batch",
name="batch",
url="https://ircv3.net/specs/extensions/batch-3.2.html",
standard="IRCv3",
),
CapDef(
identifier="CapNotify",
name="cap-notify",
url="https://ircv3.net/specs/extensions/cap-notify-3.2.html",
standard="IRCv3",
),
CapDef(
identifier="ChgHost",
name="chghost",
url="https://ircv3.net/specs/extensions/chghost-3.2.html",
standard="IRCv3",
),
CapDef(
identifier="EchoMessage",
name="echo-message",
url="https://ircv3.net/specs/extensions/echo-message-3.2.html",
standard="IRCv3",
),
CapDef(
identifier="ExtendedJoin",
name="extended-join",
url="https://ircv3.net/specs/extensions/extended-join-3.1.html",
standard="IRCv3",
),
CapDef(
identifier="ExtendedMonitor",
name="extended-monitor",
url="https://ircv3.net/specs/extensions/extended-monitor.html",
standard="IRCv3",
),
CapDef(
identifier="InviteNotify",
name="invite-notify",
url="https://ircv3.net/specs/extensions/invite-notify-3.2.html",
standard="IRCv3",
),
CapDef(
identifier="LabeledResponse",
name="labeled-response",
url="https://ircv3.net/specs/extensions/labeled-response.html",
standard="IRCv3",
),
CapDef(
identifier="Languages",
name="draft/languages",
url="https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6",
standard="proposed IRCv3",
),
CapDef(
identifier="MessageRedaction",
name="draft/message-redaction",
url="https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md",
standard="proposed IRCv3",
),
CapDef(
identifier="MessageTags",
name="message-tags",
url="https://ircv3.net/specs/extensions/message-tags.html",
standard="IRCv3",
),
CapDef(
identifier="MultiPrefix",
name="multi-prefix",
url="https://ircv3.net/specs/extensions/multi-prefix-3.1.html",
standard="IRCv3",
),
CapDef(
identifier="Relaymsg",
name="draft/relaymsg",
url="https://github.com/ircv3/ircv3-specifications/pull/417",
standard="proposed IRCv3",
),
CapDef(
identifier="ChannelRename",
name="draft/channel-rename",
url="https://ircv3.net/specs/extensions/channel-rename",
standard="draft IRCv3",
),
CapDef(
identifier="SASL",
name="sasl",
url="https://ircv3.net/specs/extensions/sasl-3.2.html",
standard="IRCv3",
),
CapDef(
identifier="ServerTime",
name="server-time",
url="https://ircv3.net/specs/extensions/server-time-3.2.html",
standard="IRCv3",
),
CapDef(
identifier="SetName",
name="setname",
url="https://ircv3.net/specs/extensions/setname.html",
standard="IRCv3",
),
CapDef(
identifier="STS",
name="sts",
url="https://ircv3.net/specs/extensions/sts.html",
standard="IRCv3",
),
CapDef(
identifier="UserhostInNames",
name="userhost-in-names",
url="https://ircv3.net/specs/extensions/userhost-in-names-3.2.html",
standard="IRCv3",
),
CapDef(
identifier="ZNCSelfMessage",
name="znc.in/self-message",
url="https://wiki.znc.in/Query_buffers",
standard="ZNC vendor",
),
CapDef(
identifier="EventPlayback",
name="draft/event-playback",
url="https://github.com/ircv3/ircv3-specifications/pull/362",
standard="proposed IRCv3",
),
CapDef(
identifier="ZNCPlayback",
name="znc.in/playback",
url="https://wiki.znc.in/Playback",
standard="ZNC vendor",
),
CapDef(
identifier="Nope",
name="ergo.chat/nope",
url="https://ergo.chat/nope",
standard="Ergo vendor",
),
CapDef(
identifier="Multiline",
name="draft/multiline",
url="https://github.com/ircv3/ircv3-specifications/pull/398",
standard="proposed IRCv3",
),
CapDef(
identifier="Chathistory",
name="draft/chathistory",
url="https://github.com/ircv3/ircv3-specifications/pull/393",
standard="proposed IRCv3",
),
CapDef(
identifier="AccountRegistration",
name="draft/account-registration",
url="https://github.com/ircv3/ircv3-specifications/pull/435",
standard="draft IRCv3",
),
CapDef(
identifier="ReadMarker",
name="draft/read-marker",
url="https://github.com/ircv3/ircv3-specifications/pull/489",
standard="draft IRCv3",
),
CapDef(
identifier="Persistence",
name="draft/persistence",
url="https://github.com/ircv3/ircv3-specifications/pull/503",
standard="proposed IRCv3",
),
CapDef(
identifier="Preaway",
name="draft/pre-away",
url="https://github.com/ircv3/ircv3-specifications/pull/514",
standard="proposed IRCv3",
),
CapDef(
identifier="StandardReplies",
name="standard-replies",
url="https://github.com/ircv3/ircv3-specifications/pull/506",
standard="IRCv3",
),
CapDef(
identifier="NoImplicitNames",
name="draft/no-implicit-names",
url="https://github.com/ircv3/ircv3-specifications/pull/527",
standard="proposed IRCv3",
),
CapDef(
identifier="ExtendedISupport",
name="draft/extended-isupport",
url="https://github.com/ircv3/ircv3-specifications/pull/543",
standard="proposed IRCv3",
),
CapDef(
identifier="WebPush",
name="draft/webpush",
url="https://github.com/ircv3/ircv3-specifications/pull/471",
standard="proposed IRCv3",
),
CapDef(
identifier="SojuWebPush",
name="soju.im/webpush",
url="https://github.com/ircv3/ircv3-specifications/pull/471",
standard="Soju/Goguma vendor",
),
CapDef(
identifier="Metadata",
name="draft/metadata-2",
url="https://ircv3.net/specs/extensions/metadata",
standard="draft IRCv3",
),
]
def validate_defs():
CAPDEFS.sort(key=lambda d: d.name)
numCaps = len(CAPDEFS)
numNames = len(set(capdef.name for capdef in CAPDEFS))
if numCaps != numNames:
raise Exception("defs must have unique names, but found duplicates")
numIdentifiers = len(set(capdef.identifier for capdef in CAPDEFS))
if numCaps != numIdentifiers:
raise Exception("defs must have unique identifiers, but found duplicates")
def main():
validate_defs()
output = io.StringIO()
print("""
package caps
/*
WARNING: this file is autogenerated by `make capdefs`
DO NOT EDIT MANUALLY.
*/
""", file=output)
numCapabs = len(CAPDEFS)
bitsetLen = numCapabs // 32
if numCapabs % 32 > 0:
bitsetLen += 1
print ("""
const (
// number of recognized capabilities:
numCapabs = %d
// length of the uint32 array that represents the bitset:
bitsetLen = %d
)
""" % (numCapabs, bitsetLen), file=output)
print("const (", file=output)
for capdef in CAPDEFS:
print("// %s is the %s capability named \"%s\":" % (capdef.identifier, capdef.standard, capdef.name), file=output)
print("// %s" % (capdef.url,), file=output)
print("%s Capability = iota" % (capdef.identifier,), file=output)
print(file=output)
print(")", file=output)
print("// `capabilityNames[capab]` is the string name of the capability `capab`", file=output)
print("""var ( capabilityNames = [numCapabs]string{""", file=output)
for capdef in CAPDEFS:
print("\"%s\"," % (capdef.name,), file=output)
print("})", file=output)
# run the generated code through `gofmt -s`, which will print it to stdout
gofmt = subprocess.Popen(['gofmt', '-s'], stdin=subprocess.PIPE)
gofmt.communicate(input=output.getvalue().encode('utf-8'))
if gofmt.poll() != 0:
print(output.getvalue())
raise Exception("gofmt failed")
return 0
if __name__ == '__main__':
sys.exit(main())

47
go.mod
View File

@ -1,47 +0,0 @@
module github.com/ergochat/ergo
go 1.24
require (
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881
github.com/ergochat/irc-go v0.5.0-rc2
github.com/go-sql-driver/mysql v1.7.0
github.com/gofrs/flock v0.8.1
github.com/gorilla/websocket v1.4.2
github.com/okzk/sdnotify v0.0.0-20180710141335-d9becc38acbd
github.com/onsi/ginkgo v1.12.0 // indirect
github.com/onsi/gomega v1.9.0 // indirect
github.com/stretchr/testify v1.4.0 // indirect
github.com/tidwall/buntdb v1.3.2
github.com/xdg-go/scram v1.0.2
golang.org/x/crypto v0.38.0
golang.org/x/term v0.32.0
golang.org/x/text v0.25.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/emersion/go-msgauth v0.7.0
github.com/ergochat/webpush-go/v2 v2.0.0
github.com/golang-jwt/jwt/v5 v5.2.2
)
require (
github.com/tidwall/btree v1.4.2 // indirect
github.com/tidwall/gjson v1.14.3 // indirect
github.com/tidwall/grect v0.1.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/rtred v0.1.2 // indirect
github.com/tidwall/tinyqueue v0.1.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
golang.org/x/sys v0.33.0 // indirect
)
replace github.com/gorilla/websocket => github.com/ergochat/websocket v1.4.2-oragono1
replace github.com/xdg-go/scram => github.com/ergochat/scram v1.0.2-ergo1

109
go.sum
View File

@ -1,109 +0,0 @@
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=
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/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/emersion/go-msgauth v0.6.8 h1:kW/0E9E8Zx5CdKsERC/WnAvnXvX7q9wTHia1OA4944A=
github.com/emersion/go-msgauth v0.6.8/go.mod h1:YDwuyTCUHu9xxmAeVj0eW4INnwB6NNZoPdLerpSxRrc=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 h1:WLHTOodthVyv5NvYLIvWl112kSFv5IInKKrRN2qpons=
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk=
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms=
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM=
github.com/ergochat/irc-go v0.5.0-rc2 h1:VuSQJF5K4hWvYSzGa4b8vgL6kzw8HF6LSOejE+RWpAo=
github.com/ergochat/irc-go v0.5.0-rc2/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g=
github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/ergochat/webpush-go/v2 v2.0.0 h1:n6eoJk8RpzJFeBJ6gxvqo/dngnVEmJbzJwzKtCZbByo=
github.com/ergochat/webpush-go/v2 v2.0.0/go.mod h1:OQlhnq8JeHDzRzAy6bdDObr19uqbHliOV+z7mHbYr4c=
github.com/ergochat/websocket v1.4.2-oragono1 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM=
github.com/ergochat/websocket v1.4.2-oragono1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
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/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
github.com/tidwall/btree v1.4.2 h1:PpkaieETJMUxYNADsjgtNRcERX7mGc/GP2zp/r5FM3g=
github.com/tidwall/btree v1.4.2/go.mod h1:LGm8L/DZjPLmeWGjv5kFrY8dL4uVhMmzmmLYmsObdKE=
github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg=
github.com/tidwall/buntdb v1.3.2/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU=
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8=
github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ=
github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE=
github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc=
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
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/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.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,115 +0,0 @@
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package irc
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"net"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/utils"
)
// JSON-serializable input and output types for the script
type AuthScriptInput struct {
AccountName string `json:"accountName,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
Certfp string `json:"certfp,omitempty"`
PeerCerts []string `json:"peerCerts,omitempty"`
peerCerts []*x509.Certificate
IP string `json:"ip,omitempty"`
OAuthBearer *oauth2.OAuthBearerOptions `json:"oauth2,omitempty"`
}
type AuthScriptOutput struct {
AccountName string `json:"accountName"`
Success bool `json:"success"`
Error string `json:"error"`
}
func CheckAuthScript(sem utils.Semaphore, config ScriptConfig, input AuthScriptInput) (output AuthScriptOutput, err error) {
if sem != nil {
sem.Acquire()
defer sem.Release()
}
// PEM-encode the peer certificates before applying JSON
if len(input.peerCerts) != 0 {
input.PeerCerts = make([]string, len(input.peerCerts))
for i, cert := range input.peerCerts {
input.PeerCerts[i] = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))
}
}
inputBytes, err := json.Marshal(input)
if err != nil {
return
}
outBytes, err := RunScript(config.Command, config.Args, inputBytes, config.Timeout, config.KillTimeout)
if err != nil {
return
}
err = json.Unmarshal(outBytes, &output)
if err != nil {
return
}
if output.Error != "" {
err = fmt.Errorf("Authentication process reported error: %s", output.Error)
}
return
}
type IPScriptResult uint
const (
IPNotChecked IPScriptResult = 0
IPAccepted IPScriptResult = 1
IPBanned IPScriptResult = 2
IPRequireSASL IPScriptResult = 3
)
type IPScriptInput struct {
IP string `json:"ip"`
}
type IPScriptOutput struct {
Result IPScriptResult `json:"result"`
BanMessage string `json:"banMessage"`
// for caching: the network to which this result is applicable, and a TTL in seconds:
CacheNet string `json:"cacheNet"`
CacheSeconds int `json:"cacheSeconds"`
Error string `json:"error"`
}
func CheckIPBan(sem utils.Semaphore, config IPCheckScriptConfig, addr net.IP) (output IPScriptOutput, err error) {
if sem != nil {
sem.Acquire()
defer sem.Release()
}
inputBytes, err := json.Marshal(IPScriptInput{IP: addr.String()})
if err != nil {
return
}
outBytes, err := RunScript(config.Command, config.Args, inputBytes, config.Timeout, config.KillTimeout)
if err != nil {
return
}
err = json.Unmarshal(outBytes, &output)
if err != nil {
return
}
if output.Error != "" {
err = fmt.Errorf("IP ban process reported error: %s", output.Error)
} else if !(IPAccepted <= output.Result && output.Result <= IPRequireSASL) {
err = fmt.Errorf("Invalid result from IP checking script: %d", output.Result)
}
return
}

View File

@ -1,107 +0,0 @@
// 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
}
}

173
irc/capability.go Normal file
View File

@ -0,0 +1,173 @@
// Copyright (c) 2012-2014 Jeremy Latt
// Copyright (c) 2016- Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package irc
import (
"strings"
"github.com/DanielOaks/girc-go/ircmsg"
)
// Capabilities are optional features a client may request from a server.
type Capability string
const (
AccountTag Capability = "account-tag"
AccountNotify Capability = "account-notify"
AwayNotify Capability = "away-notify"
CapNotify Capability = "cap-notify"
EchoMessage Capability = "echo-message"
ExtendedJoin Capability = "extended-join"
InviteNotify Capability = "invite-notify"
MessageTags Capability = "draft/message-tags"
MultiPrefix Capability = "multi-prefix"
SASL Capability = "sasl"
ServerTime Capability = "server-time"
UserhostInNames Capability = "userhost-in-names"
)
var (
SupportedCapabilities = CapabilitySet{
AccountTag: true,
AccountNotify: true,
AwayNotify: true,
CapNotify: true,
EchoMessage: true,
ExtendedJoin: true,
InviteNotify: true,
MessageTags: true,
MultiPrefix: true,
// SASL is set during server startup
ServerTime: true,
UserhostInNames: true,
}
CapValues = map[Capability]string{
SASL: "PLAIN,EXTERNAL",
}
)
func (capability Capability) String() string {
return string(capability)
}
// CapModifiers are indicators showing the state of a capability after a REQ or
// ACK.
type CapModifier rune
const (
Ack CapModifier = '~'
Disable CapModifier = '-'
Sticky CapModifier = '='
)
func (mod CapModifier) String() string {
return string(mod)
}
type CapState uint
const (
CapNone CapState = iota
CapNegotiating CapState = iota
CapNegotiated CapState = iota
)
// CapVersion is used to select which max version of CAP the client supports.
type CapVersion uint
const (
// Cap301 refers to the base CAP spec.
Cap301 CapVersion = 301
// Cap302 refers to the IRCv3.2 CAP spec.
Cap302 CapVersion = 302
)
// CapabilitySet is used to track supported, enabled, and existing caps.
type CapabilitySet map[Capability]bool
func (set CapabilitySet) String(version CapVersion) string {
strs := make([]string, len(set))
index := 0
for capability := range set {
capString := string(capability)
if version == Cap302 {
val, exists := CapValues[capability]
if exists {
capString += "=" + val
}
}
strs[index] = capString
index++
}
return strings.Join(strs, " ")
}
func (set CapabilitySet) DisableString() string {
parts := make([]string, len(set))
index := 0
for capability := range set {
parts[index] = Disable.String() + capability.String()
index += 1
}
return strings.Join(parts, " ")
}
// CAP <subcmd> [<caps>]
func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool {
subCommand := strings.ToUpper(msg.Params[0])
capabilities := make(CapabilitySet)
var capString string
if len(msg.Params) > 1 {
capString = msg.Params[1]
strs := strings.Split(capString, " ")
for _, str := range strs {
if len(str) > 0 {
capabilities[Capability(str)] = true
}
}
}
switch subCommand {
case "LS":
if !client.registered {
client.capState = CapNegotiating
}
if len(msg.Params) > 1 && msg.Params[1] == "302" {
client.capVersion = 302
}
// weechat 1.4 has a bug here where it won't accept the CAP reply unless it contains
// the server.name source... otherwise it doesn't respond to the CAP message with
// anything and just hangs on connection.
//TODO(dan): limit number of caps and send it multiline in 3.2 style as appropriate.
client.Send(nil, server.name, "CAP", client.nick, subCommand, SupportedCapabilities.String(client.capVersion))
case "LIST":
client.Send(nil, server.name, "CAP", client.nick, subCommand, client.capabilities.String(Cap301)) // values not sent on LIST so force 3.1
case "REQ":
// make sure all capabilities actually exist
for capability := range capabilities {
if !SupportedCapabilities[capability] {
client.Send(nil, server.name, "CAP", client.nick, "NAK", capString)
return false
}
}
for capability := range capabilities {
client.capabilities[capability] = true
}
client.Send(nil, server.name, "CAP", client.nick, "ACK", capString)
case "END":
if !client.registered {
client.capState = CapNegotiated
server.tryRegister(client)
}
default:
client.Send(nil, server.name, ERR_INVALIDCAPCMD, client.nick, subCommand, "Invalid CAP subcommand")
}
return false
}

View File

@ -1,75 +0,0 @@
// Copyright (c) 2017 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package caps
import "errors"
// Capability represents an optional feature that a client may request from the server.
type Capability uint
// actual capability definitions appear in defs.go
var (
nameToCapability map[string]Capability
NoSuchCap = errors.New("Unsupported capability name")
)
// Name returns the name of the given capability.
func (capability Capability) Name() string {
return capabilityNames[capability]
}
func NameToCapability(name string) (result Capability, err error) {
result, found := nameToCapability[name]
if !found {
err = NoSuchCap
}
return
}
// Version is used to select which max version of CAP the client supports.
type Version uint
const (
// Cap301 refers to the base CAP spec.
Cap301 Version = 301
// Cap302 refers to the IRCv3.2 CAP spec.
Cap302 Version = 302
)
// State shows whether we're negotiating caps, finished, etc for connection registration.
type State uint
const (
// NoneState means CAP hasn't been negotiated at all.
NoneState State = iota
// NegotiatingState means CAP is being negotiated and registration should be paused.
NegotiatingState State = iota
// NegotiatedState means CAP negotiation has been successfully ended and reg should complete.
NegotiatedState State = iota
)
const (
// LabelTagName is the tag name used for the labeled-response spec.
// https://ircv3.net/specs/extensions/labeled-response.html
LabelTagName = "label"
// More draft names associated with draft/multiline:
MultilineBatchType = "draft/multiline"
MultilineConcatTag = "draft/multiline-concat"
// draft/relaymsg:
RelaymsgTagName = "draft/relaymsg"
// BOT mode: https://ircv3.net/specs/extensions/bot-mode
BotTagName = "bot"
// https://ircv3.net/specs/extensions/chathistory
ChathistoryTargetsBatchType = "draft/chathistory-targets"
ExtendedISupportBatchType = "draft/isupport"
)
func init() {
nameToCapability = make(map[string]Capability, numCapabs)
for capab, name := range capabilityNames {
nameToCapability[name] = Capability(capab)
}
}

View File

@ -1,211 +0,0 @@
package caps
/*
WARNING: this file is autogenerated by `make capdefs`
DO NOT EDIT MANUALLY.
*/
const (
// number of recognized capabilities:
numCapabs = 38
// length of the uint32 array that represents the bitset:
bitsetLen = 2
)
const (
// AccountNotify is the IRCv3 capability named "account-notify":
// https://ircv3.net/specs/extensions/account-notify-3.1.html
AccountNotify Capability = iota
// AccountTag is the IRCv3 capability named "account-tag":
// https://ircv3.net/specs/extensions/account-tag-3.2.html
AccountTag Capability = iota
// AwayNotify is the IRCv3 capability named "away-notify":
// https://ircv3.net/specs/extensions/away-notify-3.1.html
AwayNotify Capability = iota
// Batch is the IRCv3 capability named "batch":
// https://ircv3.net/specs/extensions/batch-3.2.html
Batch Capability = iota
// CapNotify is the IRCv3 capability named "cap-notify":
// https://ircv3.net/specs/extensions/cap-notify-3.2.html
CapNotify Capability = iota
// ChgHost is the IRCv3 capability named "chghost":
// https://ircv3.net/specs/extensions/chghost-3.2.html
ChgHost Capability = iota
// AccountRegistration is the draft IRCv3 capability named "draft/account-registration":
// https://github.com/ircv3/ircv3-specifications/pull/435
AccountRegistration Capability = iota
// ChannelRename is the draft IRCv3 capability named "draft/channel-rename":
// https://ircv3.net/specs/extensions/channel-rename
ChannelRename Capability = iota
// Chathistory is the proposed IRCv3 capability named "draft/chathistory":
// https://github.com/ircv3/ircv3-specifications/pull/393
Chathistory Capability = iota
// EventPlayback is the proposed IRCv3 capability named "draft/event-playback":
// https://github.com/ircv3/ircv3-specifications/pull/362
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":
// https://gist.github.com/DanielOaks/8126122f74b26012a3de37db80e4e0c6
Languages Capability = iota
// MessageRedaction is the proposed IRCv3 capability named "draft/message-redaction":
// https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md
MessageRedaction Capability = iota
// Metadata is the draft IRCv3 capability named "draft/metadata-2":
// https://ircv3.net/specs/extensions/metadata
Metadata Capability = iota
// Multiline is the proposed IRCv3 capability named "draft/multiline":
// https://github.com/ircv3/ircv3-specifications/pull/398
Multiline Capability = iota
// NoImplicitNames is the proposed IRCv3 capability named "draft/no-implicit-names":
// https://github.com/ircv3/ircv3-specifications/pull/527
NoImplicitNames Capability = iota
// Persistence is the proposed IRCv3 capability named "draft/persistence":
// https://github.com/ircv3/ircv3-specifications/pull/503
Persistence Capability = iota
// Preaway is the proposed IRCv3 capability named "draft/pre-away":
// https://github.com/ircv3/ircv3-specifications/pull/514
Preaway Capability = iota
// ReadMarker is the draft IRCv3 capability named "draft/read-marker":
// https://github.com/ircv3/ircv3-specifications/pull/489
ReadMarker Capability = iota
// Relaymsg is the proposed IRCv3 capability named "draft/relaymsg":
// https://github.com/ircv3/ircv3-specifications/pull/417
Relaymsg Capability = iota
// WebPush is the proposed IRCv3 capability named "draft/webpush":
// https://github.com/ircv3/ircv3-specifications/pull/471
WebPush Capability = iota
// EchoMessage is the IRCv3 capability named "echo-message":
// https://ircv3.net/specs/extensions/echo-message-3.2.html
EchoMessage Capability = iota
// Nope is the Ergo vendor capability named "ergo.chat/nope":
// https://ergo.chat/nope
Nope Capability = iota
// ExtendedJoin is the IRCv3 capability named "extended-join":
// https://ircv3.net/specs/extensions/extended-join-3.1.html
ExtendedJoin Capability = iota
// ExtendedMonitor is the IRCv3 capability named "extended-monitor":
// https://ircv3.net/specs/extensions/extended-monitor.html
ExtendedMonitor Capability = iota
// InviteNotify is the IRCv3 capability named "invite-notify":
// https://ircv3.net/specs/extensions/invite-notify-3.2.html
InviteNotify Capability = iota
// LabeledResponse is the IRCv3 capability named "labeled-response":
// https://ircv3.net/specs/extensions/labeled-response.html
LabeledResponse Capability = iota
// MessageTags is the IRCv3 capability named "message-tags":
// https://ircv3.net/specs/extensions/message-tags.html
MessageTags Capability = iota
// MultiPrefix is the IRCv3 capability named "multi-prefix":
// https://ircv3.net/specs/extensions/multi-prefix-3.1.html
MultiPrefix Capability = iota
// SASL is the IRCv3 capability named "sasl":
// https://ircv3.net/specs/extensions/sasl-3.2.html
SASL Capability = iota
// ServerTime is the IRCv3 capability named "server-time":
// https://ircv3.net/specs/extensions/server-time-3.2.html
ServerTime Capability = iota
// SetName is the IRCv3 capability named "setname":
// https://ircv3.net/specs/extensions/setname.html
SetName Capability = iota
// SojuWebPush is the Soju/Goguma vendor capability named "soju.im/webpush":
// https://github.com/ircv3/ircv3-specifications/pull/471
SojuWebPush Capability = iota
// StandardReplies is the IRCv3 capability named "standard-replies":
// https://github.com/ircv3/ircv3-specifications/pull/506
StandardReplies Capability = iota
// STS is the IRCv3 capability named "sts":
// https://ircv3.net/specs/extensions/sts.html
STS Capability = iota
// UserhostInNames is the IRCv3 capability named "userhost-in-names":
// https://ircv3.net/specs/extensions/userhost-in-names-3.2.html
UserhostInNames Capability = iota
// ZNCPlayback is the ZNC vendor capability named "znc.in/playback":
// https://wiki.znc.in/Playback
ZNCPlayback Capability = iota
// ZNCSelfMessage is the ZNC vendor capability named "znc.in/self-message":
// https://wiki.znc.in/Query_buffers
ZNCSelfMessage Capability = iota
)
// `capabilityNames[capab]` is the string name of the capability `capab`
var (
capabilityNames = [numCapabs]string{
"account-notify",
"account-tag",
"away-notify",
"batch",
"cap-notify",
"chghost",
"draft/account-registration",
"draft/channel-rename",
"draft/chathistory",
"draft/event-playback",
"draft/extended-isupport",
"draft/languages",
"draft/message-redaction",
"draft/metadata-2",
"draft/multiline",
"draft/no-implicit-names",
"draft/persistence",
"draft/pre-away",
"draft/read-marker",
"draft/relaymsg",
"draft/webpush",
"echo-message",
"ergo.chat/nope",
"extended-join",
"extended-monitor",
"invite-notify",
"labeled-response",
"message-tags",
"multi-prefix",
"sasl",
"server-time",
"setname",
"soju.im/webpush",
"standard-replies",
"sts",
"userhost-in-names",
"znc.in/playback",
"znc.in/self-message",
}
)

View File

@ -1,143 +0,0 @@
// Copyright (c) 2017 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package caps
import (
"fmt"
"github.com/ergochat/ergo/irc/utils"
)
// Set holds a set of enabled capabilities.
type Set [bitsetLen]uint32
// Values holds capability values.
type Values map[Capability]string
// NewSet returns a new Set, with the given capabilities enabled.
func NewSet(capabs ...Capability) *Set {
var newSet Set
newSet.Enable(capabs...)
return &newSet
}
// NewCompleteSet returns a new Set, with all defined capabilities enabled.
func NewCompleteSet() *Set {
var newSet Set
asSlice := newSet[:]
for i := 0; i < numCapabs; i += 1 {
utils.BitsetSet(asSlice, uint(i), true)
}
return &newSet
}
// Enable enables the given capabilities.
func (s *Set) Enable(capabs ...Capability) {
asSlice := s[:]
for _, capab := range capabs {
utils.BitsetSet(asSlice, uint(capab), true)
}
}
// Disable disables the given capabilities.
func (s *Set) Disable(capabs ...Capability) {
asSlice := s[:]
for _, capab := range capabs {
utils.BitsetSet(asSlice, uint(capab), false)
}
}
// Add adds the given capabilities to this set.
// this is just a wrapper to allow more clear use.
func (s *Set) Add(capabs ...Capability) {
s.Enable(capabs...)
}
// Remove removes the given capabilities from this set.
// this is just a wrapper to allow more clear use.
func (s *Set) Remove(capabs ...Capability) {
s.Disable(capabs...)
}
// Has returns true if this set has the given capability.
func (s *Set) Has(capab Capability) bool {
return utils.BitsetGet(s[:], uint(capab))
}
// HasAll returns true if the set has all the given capabilities.
func (s *Set) HasAll(capabs ...Capability) bool {
for _, capab := range capabs {
if !s.Has(capab) {
return false
}
}
return true
}
// Union adds all the capabilities of another set to this set.
func (s *Set) Union(other *Set) {
utils.BitsetUnion(s[:], other[:])
}
// Subtract removes all the capabilities of another set from this set.
func (s *Set) Subtract(other *Set) {
utils.BitsetSubtract(s[:], other[:])
}
// Empty returns whether the set is empty.
func (s *Set) Empty() bool {
return utils.BitsetEmpty(s[:])
}
const defaultMaxPayloadLength = 450
// Strings returns all of our enabled capabilities as a slice of strings.
func (s *Set) Strings(version Version, values Values, maxLen int) (result []string) {
if maxLen == 0 {
maxLen = defaultMaxPayloadLength
}
var t utils.TokenLineBuilder
t.Initialize(maxLen, " ")
var capab Capability
asSlice := s[:]
for capab = 0; capab < numCapabs; capab++ {
// XXX clients that only support CAP LS 301 cannot handle multiline
// responses. omit some CAPs in this case, forcing the response to fit on
// a single line. this is technically buggy for CAP LIST (as opposed to LS)
// but it shouldn't matter
if version < Cap302 && !isAllowed301(capab) {
continue
}
// skip any capabilities that are not enabled
if !utils.BitsetGet(asSlice, uint(capab)) {
continue
}
capString := capab.Name()
if version >= Cap302 {
val, exists := values[capab]
if exists {
capString = fmt.Sprintf("%s=%s", capString, val)
}
}
t.Add(capString)
}
result = t.Lines()
if result == nil {
result = []string{""}
}
return
}
// this is a fixed whitelist of caps that are eligible for display in CAP LS 301
func isAllowed301(capab Capability) bool {
switch capab {
case AccountNotify, AccountTag, AwayNotify, Batch, ChgHost, Chathistory, EventPlayback,
Relaymsg, EchoMessage, Nope, ExtendedJoin, InviteNotify, LabeledResponse, MessageTags,
MultiPrefix, SASL, ServerTime, SetName, STS, UserhostInNames, ZNCSelfMessage, ZNCPlayback:
return true
default:
return false
}
}

View File

@ -1,109 +0,0 @@
// Copyright (c) 2017 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package caps
import (
"fmt"
"reflect"
"testing"
)
func TestSets(t *testing.T) {
s1 := NewSet()
s1.Enable(AccountTag, EchoMessage, UserhostInNames)
if !(s1.Has(AccountTag) && s1.Has(EchoMessage) && s1.Has(UserhostInNames)) {
t.Error("Did not have the tags we expected")
}
if s1.Has(STS) {
t.Error("Has() returned true when we don't have the given capability")
}
s1.Disable(AccountTag)
if s1.Has(AccountTag) {
t.Error("Disable() did not correctly disable the given capability")
}
enabledCaps := NewSet()
enabledCaps.Union(s1)
expectedCaps := NewSet(EchoMessage, UserhostInNames)
if !reflect.DeepEqual(enabledCaps, expectedCaps) {
t.Errorf("Enabled and expected capability lists do not match: %v, %v", enabledCaps, expectedCaps)
}
// make sure re-enabling doesn't add to the count or something weird like that
s1.Enable(EchoMessage)
// make sure add and remove work fine
s1.Add(InviteNotify)
s1.Remove(EchoMessage)
if !s1.Has(InviteNotify) || s1.Has(EchoMessage) {
t.Error("Add/Remove don't work")
}
// test Strings()
values := make(Values)
values[InviteNotify] = "invitemepls"
actualCap301ValuesString := s1.Strings(Cap301, values, 0)
expectedCap301ValuesString := []string{"invite-notify userhost-in-names"}
if !reflect.DeepEqual(actualCap301ValuesString, expectedCap301ValuesString) {
t.Errorf("Generated Cap301 values string [%v] did not match expected values string [%v]", actualCap301ValuesString, expectedCap301ValuesString)
}
actualCap302ValuesString := s1.Strings(Cap302, values, 0)
expectedCap302ValuesString := []string{"invite-notify=invitemepls userhost-in-names"}
if !reflect.DeepEqual(actualCap302ValuesString, expectedCap302ValuesString) {
t.Errorf("Generated Cap302 values string [%s] did not match expected values string [%s]", actualCap302ValuesString, expectedCap302ValuesString)
}
}
func assertEqual(found, expected interface{}) {
if !reflect.DeepEqual(found, expected) {
panic(fmt.Sprintf("found %#v, expected %#v", found, expected))
}
}
func Test301WhitelistNotRespectedFor302(t *testing.T) {
s1 := NewSet()
s1.Enable(AccountTag, EchoMessage, StandardReplies)
assertEqual(s1.Strings(Cap301, nil, 0), []string{"account-tag echo-message"})
assertEqual(s1.Strings(Cap302, nil, 0), []string{"account-tag echo-message standard-replies"})
}
func TestSubtract(t *testing.T) {
s1 := NewSet(AccountTag, EchoMessage, UserhostInNames, ServerTime)
toRemove := NewSet(UserhostInNames, EchoMessage)
s1.Subtract(toRemove)
if !reflect.DeepEqual(s1, NewSet(AccountTag, ServerTime)) {
t.Errorf("subtract doesn't work")
}
}
func BenchmarkSetReads(b *testing.B) {
set := NewSet(UserhostInNames, EchoMessage)
b.ResetTimer()
for i := 0; i < b.N; i++ {
set.Has(UserhostInNames)
set.Has(LabeledResponse)
set.Has(EchoMessage)
set.Has(Nope)
}
}
func BenchmarkSetWrites(b *testing.B) {
for i := 0; i < b.N; i++ {
set := NewSet(UserhostInNames, EchoMessage)
set.Add(Nope)
set.Add(ExtendedJoin)
set.Remove(UserhostInNames)
set.Remove(LabeledResponse)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,515 +0,0 @@
// Copyright (c) 2017 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package irc
import (
"sort"
"sync"
"time"
"github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/utils"
)
type channelManagerEntry struct {
channel *Channel
// this is a refcount for joins, so we can avoid a race where we incorrectly
// think the channel is empty (without holding a lock across the entire Channel.Join()
// call)
pendingJoins int
skeleton string
}
// ChannelManager keeps track of all the channels on the server,
// providing synchronization for creation of new channels on first join,
// cleanup of empty channels on last part, and renames.
type ChannelManager struct {
sync.RWMutex // tier 2
// chans is the main data structure, mapping casefolded name -> *Channel
chans map[string]*channelManagerEntry
chansSkeletons utils.HashSet[string]
purgedChannels map[string]ChannelPurgeRecord // casefolded name to purge record
server *Server
}
// NewChannelManager returns a new ChannelManager.
func (cm *ChannelManager) Initialize(server *Server, config *Config) (err error) {
cm.chans = make(map[string]*channelManagerEntry)
cm.chansSkeletons = make(utils.HashSet[string])
cm.server = server
return cm.loadRegisteredChannels(config)
}
func (cm *ChannelManager) loadRegisteredChannels(config *Config) (err error) {
allChannels, err := FetchAndDeserializeAll[RegisteredChannel](datastore.TableChannels, cm.server.dstore, cm.server.logger)
if err != nil {
return
}
allPurgeRecords, err := FetchAndDeserializeAll[ChannelPurgeRecord](datastore.TableChannelPurges, cm.server.dstore, cm.server.logger)
if err != nil {
return
}
cm.Lock()
defer cm.Unlock()
cm.purgedChannels = make(map[string]ChannelPurgeRecord, len(allPurgeRecords))
for _, purge := range allPurgeRecords {
cm.purgedChannels[purge.NameCasefolded] = purge
}
for _, regInfo := range allChannels {
cfname, err := CasefoldChannel(regInfo.Name)
if err != nil {
cm.server.logger.Error("channels", "couldn't casefold registered channel, skipping", regInfo.Name, err.Error())
continue
} else {
cm.server.logger.Debug("channels", "initializing registered channel", regInfo.Name)
}
skeleton, err := Skeleton(regInfo.Name)
if err == nil {
cm.chansSkeletons.Add(skeleton)
}
if _, ok := cm.purgedChannels[cfname]; !ok {
ch := NewChannel(cm.server, regInfo.Name, cfname, true, regInfo)
cm.chans[cfname] = &channelManagerEntry{
channel: ch,
pendingJoins: 0,
skeleton: skeleton,
}
}
}
return nil
}
// Get returns an existing channel with name equivalent to `name`, or nil
func (cm *ChannelManager) Get(name string) (channel *Channel) {
name, err := CasefoldChannel(name)
if err != nil {
return nil
}
cm.RLock()
defer cm.RUnlock()
entry := cm.chans[name]
if entry != nil {
return entry.channel
}
return nil
}
// Join causes `client` to join the channel named `name`, creating it if necessary.
func (cm *ChannelManager) Join(client *Client, name string, key string, isSajoin bool, rb *ResponseBuffer) (err error, forward string) {
server := client.server
casefoldedName, err := CasefoldChannel(name)
skeleton, skerr := Skeleton(name)
if err != nil || skerr != nil || len(casefoldedName) > server.Config().Limits.ChannelLen {
return errNoSuchChannel, ""
}
channel, err, newChannel := func() (*Channel, error, bool) {
var newChannel bool
cm.Lock()
defer cm.Unlock()
// check purges first; a registered purged channel will still be present in `chans`
if _, ok := cm.purgedChannels[casefoldedName]; ok {
return nil, errChannelPurged, false
}
entry := cm.chans[casefoldedName]
if entry == nil {
if server.Config().Channels.OpOnlyCreation &&
!(isSajoin || client.HasRoleCapabs("chanreg")) {
return nil, errInsufficientPrivs, false
}
// enforce confusables
if cm.chansSkeletons.Has(skeleton) {
return nil, errConfusableIdentifier, false
}
entry = &channelManagerEntry{
channel: NewChannel(server, name, casefoldedName, false, RegisteredChannel{}),
pendingJoins: 0,
}
cm.chansSkeletons.Add(skeleton)
entry.skeleton = skeleton
cm.chans[casefoldedName] = entry
newChannel = true
}
entry.pendingJoins += 1
return entry.channel, nil, newChannel
}()
if err != nil {
return err, ""
}
err, forward = channel.Join(client, key, isSajoin || newChannel, rb)
cm.maybeCleanup(channel, true)
return
}
func (cm *ChannelManager) maybeCleanup(channel *Channel, afterJoin bool) {
cm.Lock()
defer cm.Unlock()
cfname := channel.NameCasefolded()
entry := cm.chans[cfname]
if entry == nil || entry.channel != channel {
return
}
cm.maybeCleanupInternal(cfname, entry, afterJoin)
}
func (cm *ChannelManager) maybeCleanupInternal(cfname string, entry *channelManagerEntry, afterJoin bool) {
if afterJoin {
entry.pendingJoins -= 1
}
if entry.pendingJoins == 0 && entry.channel.IsClean() {
delete(cm.chans, cfname)
if entry.skeleton != "" {
delete(cm.chansSkeletons, entry.skeleton)
}
}
}
// Part parts `client` from the channel named `name`, deleting it if it's empty.
func (cm *ChannelManager) Part(client *Client, name string, message string, rb *ResponseBuffer) error {
var channel *Channel
casefoldedName, err := CasefoldChannel(name)
if err != nil {
return errNoSuchChannel
}
cm.RLock()
entry := cm.chans[casefoldedName]
if entry != nil {
channel = entry.channel
}
cm.RUnlock()
if channel == nil {
return errNoSuchChannel
}
channel.Part(client, message, rb)
return nil
}
func (cm *ChannelManager) Cleanup(channel *Channel) {
cm.maybeCleanup(channel, false)
}
func (cm *ChannelManager) SetRegistered(channelName string, account string) (err error) {
if account == "" {
return errAuthRequired // this is already enforced by ChanServ, but do a final check
}
if cm.server.Defcon() <= 4 {
return errFeatureDisabled
}
var channel *Channel
cfname, err := CasefoldChannel(channelName)
if err != nil {
return err
}
var entry *channelManagerEntry
defer func() {
if err == nil && channel != nil {
// registration was successful: make the database reflect it
err = channel.Store(IncludeAllAttrs)
}
}()
cm.Lock()
defer cm.Unlock()
entry = cm.chans[cfname]
if entry == nil {
return errNoSuchChannel
}
channel = entry.channel
err = channel.SetRegistered(account)
if err != nil {
return err
}
return nil
}
func (cm *ChannelManager) SetUnregistered(channelName string, account string) (err error) {
cfname, err := CasefoldChannel(channelName)
if err != nil {
return err
}
var uuid utils.UUID
defer func() {
if err == nil {
if delErr := cm.server.dstore.Delete(datastore.TableChannels, uuid); delErr != nil {
cm.server.logger.Error("datastore", "couldn't delete channel registration", cfname, delErr.Error())
}
}
}()
cm.Lock()
defer cm.Unlock()
entry := cm.chans[cfname]
if entry != nil {
if entry.channel.Founder() != account {
return errChannelNotOwnedByAccount
}
uuid = entry.channel.UUID()
entry.channel.SetUnregistered(account) // changes the UUID
// #1619: if the channel has 0 members and was only being retained
// because it was registered, clean it up:
cm.maybeCleanupInternal(cfname, entry, false)
}
return nil
}
// Rename renames a channel (but does not notify the members)
func (cm *ChannelManager) Rename(name string, newName string) (err error) {
oldCfname, err := CasefoldChannel(name)
if err != nil {
return errNoSuchChannel
}
newCfname, err := CasefoldChannel(newName)
if err != nil {
return errInvalidChannelName
}
newSkeleton, err := Skeleton(newName)
if err != nil {
return errInvalidChannelName
}
var channel *Channel
var info RegisteredChannel
defer func() {
if channel != nil && info.Founder != "" {
channel.MarkDirty(IncludeAllAttrs)
}
// always-on clients need to update their saved channel memberships
for _, member := range channel.Members() {
member.markDirty(IncludeChannels)
}
}()
cm.Lock()
defer cm.Unlock()
entry := cm.chans[oldCfname]
if entry == nil {
return errNoSuchChannel
}
channel = entry.channel
info = channel.ExportRegistration()
registered := info.Founder != ""
oldSkeleton, err := Skeleton(info.Name)
if err != nil {
return errNoSuchChannel // ugh
}
if newCfname != oldCfname {
if cm.chans[newCfname] != nil {
return errChannelNameInUse
}
}
if oldSkeleton != newSkeleton {
if cm.chansSkeletons.Has(newSkeleton) {
return errConfusableIdentifier
}
}
delete(cm.chans, oldCfname)
if !registered {
entry.skeleton = newSkeleton
}
cm.chans[newCfname] = entry
delete(cm.chansSkeletons, oldSkeleton)
cm.chansSkeletons.Add(newSkeleton)
entry.channel.Rename(newName, newCfname)
return nil
}
// Len returns the number of channels
func (cm *ChannelManager) Len() int {
cm.RLock()
defer cm.RUnlock()
return len(cm.chans)
}
// Channels returns a slice containing all current channels
func (cm *ChannelManager) Channels() (result []*Channel) {
cm.RLock()
defer cm.RUnlock()
result = make([]*Channel, 0, len(cm.chans))
for _, entry := range cm.chans {
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)
}
}
return
}
// Purge marks a channel as purged.
func (cm *ChannelManager) Purge(chname string, record ChannelPurgeRecord) (err error) {
chname, err = CasefoldChannel(chname)
if err != nil {
return errInvalidChannelName
}
record.NameCasefolded = chname
record.UUID = utils.GenerateUUIDv4()
channel, err := func() (channel *Channel, err error) {
cm.Lock()
defer cm.Unlock()
if _, ok := cm.purgedChannels[chname]; ok {
return nil, errChannelPurgedAlready
}
entry := cm.chans[chname]
// atomically prevent anyone from rejoining
cm.purgedChannels[chname] = record
if entry != nil {
channel = entry.channel
}
return
}()
if err != nil {
return err
}
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.
func (cm *ChannelManager) IsPurged(chname string) (result bool) {
chname, err := CasefoldChannel(chname)
if err != nil {
return false
}
cm.RLock()
_, result = cm.purgedChannels[chname]
cm.RUnlock()
return
}
// Unpurge deletes a channel's purged status.
func (cm *ChannelManager) Unpurge(chname string) (err error) {
chname, err = CasefoldChannel(chname)
if err != nil {
return errNoSuchChannel
}
cm.Lock()
record, found := cm.purgedChannels[chname]
delete(cm.purgedChannels, chname)
cm.Unlock()
if !found {
return errNoSuchChannel
}
if err := cm.server.dstore.Delete(datastore.TableChannelPurges, record.UUID); err != nil {
cm.server.logger.Error("datastore", "couldn't delete purge record", chname, err.Error())
}
return nil
}
func (cm *ChannelManager) ListPurged() (result []string) {
cm.RLock()
result = make([]string, 0, len(cm.purgedChannels))
for c := range cm.purgedChannels {
result = append(result, c)
}
cm.RUnlock()
sort.Strings(result)
return
}
func (cm *ChannelManager) UnfoldName(cfname string) (result string) {
cm.RLock()
entry := cm.chans[cfname]
cm.RUnlock()
if entry != nil {
return entry.channel.Name()
}
return cfname
}
func (cm *ChannelManager) LoadPurgeRecord(cfchname string) (record ChannelPurgeRecord, err error) {
cm.RLock()
defer cm.RUnlock()
if record, ok := cm.purgedChannels[cfchname]; ok {
return record, nil
} else {
return record, errNoSuchChannel
}
}
func (cm *ChannelManager) ChannelsForAccount(account string) (channels []string) {
cm.RLock()
defer cm.RUnlock()
for cfname, entry := range cm.chans {
if entry.channel.Founder() == account {
channels = append(channels, cfname)
}
}
return
}
// AllChannels returns the uncasefolded names of all registered channels.
func (cm *ChannelManager) AllRegisteredChannels() (result []string) {
cm.RLock()
defer cm.RUnlock()
for cfname, entry := range cm.chans {
if entry.channel.Founder() != "" {
result = append(result, cfname)
}
}
return
}

View File

@ -1,92 +0,0 @@
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package irc
import (
"encoding/json"
"time"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils"
)
// this is exclusively the *persistence* layer for channel registration;
// channel creation/tracking/destruction is in channelmanager.go
// these are bit flags indicating what part of the channel status is "dirty"
// and needs to be read from memory and written to the db
const (
IncludeInitial uint = 1 << iota
IncludeTopic
IncludeModes
IncludeLists
IncludeSettings
)
// this is an OR of all possible flags
const (
IncludeAllAttrs = ^uint(0)
)
// RegisteredChannel holds details about a given registered channel.
type RegisteredChannel struct {
// Name of the channel.
Name string
// UUID for the datastore.
UUID utils.UUID
// RegisteredAt represents the time that the channel was registered.
RegisteredAt time.Time
// Founder indicates the founder of the channel.
Founder string
// Topic represents the channel topic.
Topic string
// TopicSetBy represents the host that set the topic.
TopicSetBy string
// TopicSetTime represents the time the topic was set.
TopicSetTime time.Time
// Modes represents the channel modes
Modes []modes.Mode
// Key represents the channel key / password
Key string
// Forward is the forwarding/overflow (+f) channel
Forward string
// UserLimit is the user limit (0 for no limit)
UserLimit int
// AccountToUMode maps user accounts to their persistent channel modes (e.g., +q, +h)
AccountToUMode map[string]modes.Mode
// Bans represents the bans set on the channel.
Bans map[string]MaskInfo
// Excepts represents the exceptions set on the channel.
Excepts map[string]MaskInfo
// Invites represents the invite exceptions set on the channel.
Invites map[string]MaskInfo
// Settings are the chanserv-modifiable settings
Settings ChannelSettings
// Metadata set using the METADATA command
Metadata map[string]string
}
func (r *RegisteredChannel) Serialize() ([]byte, error) {
return json.Marshal(r)
}
func (r *RegisteredChannel) Deserialize(b []byte) (err error) {
return json.Unmarshal(b, r)
}
type ChannelPurgeRecord struct {
NameCasefolded string `json:"Name"`
UUID utils.UUID
Oper string
PurgedAt time.Time
Reason string
}
func (c *ChannelPurgeRecord) Serialize() ([]byte, error) {
return json.Marshal(c)
}
func (c *ChannelPurgeRecord) Deserialize(b []byte) error {
return json.Unmarshal(b, c)
}

View File

@ -1,958 +0,0 @@
// Copyright (c) 2017 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package irc
import (
"fmt"
"regexp"
"slices"
"sort"
"strings"
"time"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/irc-go/ircfmt"
)
const chanservHelp = `ChanServ lets you register and manage channels.`
func chanregEnabled(config *Config) bool {
return config.Channels.Registration.Enabled
}
var (
chanservCommands = map[string]*serviceCommand{
"op": {
handler: csOpHandler,
help: `Syntax: $bOP #channel [nickname]$b
OP makes the given nickname, or yourself, a channel admin. You can only use
this command if you're a founder or in the AMODEs of the channel.`,
helpShort: `$bOP$b makes the given user (or yourself) a channel admin.`,
authRequired: true,
enabled: chanregEnabled,
minParams: 1,
},
"deop": {
handler: csDeopHandler,
help: `Syntax: $bDEOP #channel [nickname]$b
DEOP removes the given nickname, or yourself, the channel admin. You can only use
this command if you're the founder of the channel.`,
helpShort: `$bDEOP$b removes the given user (or yourself) from a channel admin.`,
enabled: chanregEnabled,
minParams: 1,
},
"register": {
handler: csRegisterHandler,
help: `Syntax: $bREGISTER #channel$b
REGISTER lets you own the given channel. If you rejoin this channel, you'll be
given admin privs on it. Modes set on the channel and the topic will also be
remembered.`,
helpShort: `$bREGISTER$b lets you own a given channel.`,
authRequired: true,
enabled: chanregEnabled,
minParams: 1,
},
"unregister": {
handler: csUnregisterHandler,
help: `Syntax: $bUNREGISTER #channel [code]$b
UNREGISTER deletes a channel registration, allowing someone else to claim it.
To prevent accidental unregistrations, a verification code is required;
invoking the command without a code will display the necessary code.`,
helpShort: `$bUNREGISTER$b deletes a channel registration.`,
enabled: chanregEnabled,
minParams: 1,
},
"drop": {
aliasOf: "unregister",
},
"amode": {
handler: csAmodeHandler,
help: `Syntax: $bAMODE #channel [mode change] [account]$b
AMODE lists or modifies persistent mode settings that affect channel members.
For example, $bAMODE #channel +o dan$b grants the holder of the "dan"
account the +o operator mode every time they join #channel. To list current
accounts and modes, use $bAMODE #channel$b. Note that users are always
referenced by their registered account names, not their nicknames.
The permissions hierarchy for adding and removing modes is the same as in
the ordinary /MODE command.`,
helpShort: `$bAMODE$b modifies persistent mode settings for channel members.`,
enabled: chanregEnabled,
minParams: 1,
},
"clear": {
handler: csClearHandler,
help: `Syntax: $bCLEAR #channel target$b
CLEAR removes users or settings from a channel. Specifically:
$bCLEAR #channel users$b kicks all users except for you.
$bCLEAR #channel access$b resets all stored bans, invites, ban exceptions,
and persistent user-mode grants made with CS AMODE.`,
helpShort: `$bCLEAR$b removes users or settings from a channel.`,
enabled: chanregEnabled,
minParams: 2,
},
"transfer": {
handler: csTransferHandler,
help: `Syntax: $bTRANSFER [accept] #channel user [code]$b
TRANSFER transfers ownership of a channel from one user to another.
To prevent accidental transfers, a verification code is required. For
example, $bTRANSFER #channel alice$b displays the required confirmation
code, then $bTRANSFER #channel alice 2930242125$b initiates the transfer.
Unless you are an IRC operator with the correct permissions, alice must
then accept the transfer, which she can do with $bTRANSFER accept #channel$b.
To cancel a pending transfer, transfer the channel to yourself.`,
helpShort: `$bTRANSFER$b transfers ownership of a channel to another user.`,
enabled: chanregEnabled,
minParams: 2,
},
"purge": {
handler: csPurgeHandler,
help: `Syntax: $bPURGE <ADD | DEL | LIST> #channel [code] [reason]$b
PURGE ADD blacklists a channel from the server, making it impossible to join
or otherwise interact with the channel. If the channel currently has members,
they will be kicked from it. PURGE may also be applied preemptively to
channels that do not currently have members. A purge can be undone with
PURGE DEL. To list purged channels, use PURGE LIST.`,
helpShort: `$bPURGE$b blacklists a channel from the server.`,
capabs: []string{"chanreg"},
minParams: 1,
maxParams: 3,
unsplitFinalParam: true,
},
"list": {
handler: csListHandler,
help: `Syntax: $bLIST [regex]$b
LIST returns the list of registered channels, which match the given regex.
If no regex is provided, all registered channels are returned.`,
helpShort: `$bLIST$b searches the list of registered channels.`,
capabs: []string{"chanreg"},
minParams: 0,
},
"info": {
handler: csInfoHandler,
help: `Syntax: $INFO #channel$b
INFO displays info about a registered channel.`,
helpShort: `$bINFO$b displays info about a registered channel.`,
enabled: chanregEnabled,
},
"get": {
handler: csGetHandler,
help: `Syntax: $bGET #channel <setting>$b
GET queries the current values of the channel settings. For more information
on the settings and their possible values, see HELP SET.`,
helpShort: `$bGET$b queries the current values of a channel's settings`,
enabled: chanregEnabled,
minParams: 2,
},
"set": {
handler: csSetHandler,
helpShort: `$bSET$b modifies a channel's settings`,
// these are broken out as separate strings so they can be translated separately
helpStrings: []string{
`Syntax $bSET #channel <setting> <value>$b
SET modifies a channel's settings. The following settings are available:`,
`$bHISTORY$b
'history' lets you control how channel history is stored. Your options are:
1. 'off' [no history]
2. 'ephemeral' [a limited amount of temporary history, not stored on disk]
3. 'on' [history stored in a permanent database, if available]
4. 'default' [use the server default]`,
`$bQUERY-CUTOFF$b
'query-cutoff' lets you restrict how much channel history can be retrieved
by unprivileged users. Your options are:
1. 'none' [no restrictions]
2. 'registration-time' [users can view history from after their account was
registered, plus a grace period]
3. 'join-time' [users can view history from after they joined the
channel; note that history will be effectively
unavailable to clients that are not always-on]
4. 'default' [use the server default]`,
},
enabled: chanregEnabled,
minParams: 3,
},
"howtoban": {
handler: csHowToBanHandler,
helpShort: `$bHOWTOBAN$b suggests the best available way of banning a user`,
help: `Syntax: $bHOWTOBAN #channel <nick>
The best way to ban a user from a channel will depend on how they are
connected to the server. $bHOWTOBAN$b suggests a ban command that will
(ideally) prevent the user from returning to the channel.`,
enabled: chanregEnabled,
minParams: 2,
},
}
)
func csAmodeHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
channelName := params[0]
channel := server.channels.Get(channelName)
if channel == nil {
service.Notice(rb, client.t("Channel does not exist"))
return
} else if channel.Founder() == "" {
service.Notice(rb, client.t("Channel is not registered"))
return
}
modeChanges, unknown := modes.ParseChannelModeChanges(params[1:]...)
invalid := len(unknown) != 0
// #2002: +f takes an argument but is not a channel-user mode,
// check for anything valid as a channel mode change that is not valid
// as an AMODE change
for _, modeChange := range modeChanges {
if !slices.Contains(modes.ChannelUserModes, modeChange.Mode) {
invalid = true
}
}
var change modes.ModeChange
if len(modeChanges) > 1 || invalid {
service.Notice(rb, client.t("Invalid mode change"))
return
} else if len(modeChanges) == 1 {
change = modeChanges[0]
} else {
change = modes.ModeChange{Op: modes.List}
}
// normalize and validate the account argument
accountIsValid := false
change.Arg, _ = CasefoldName(change.Arg)
switch change.Op {
case modes.List:
accountIsValid = true
case modes.Add:
// if we're adding a mode, the account must exist
if change.Arg != "" {
_, err := server.accounts.LoadAccount(change.Arg)
accountIsValid = (err == nil)
}
case modes.Remove:
// allow removal of accounts that may have been deleted
accountIsValid = (change.Arg != "")
}
if !accountIsValid {
service.Notice(rb, client.t("Account does not exist"))
return
}
affectedModes, err := channel.ProcessAccountToUmodeChange(client, change)
if err == errInsufficientPrivs {
service.Notice(rb, client.t("Insufficient privileges"))
return
} else if err != nil {
service.Notice(rb, client.t("Internal error"))
return
}
switch change.Op {
case modes.List:
// sort the persistent modes in descending order of priority
sort.Slice(affectedModes, func(i, j int) bool {
return umodeGreaterThan(affectedModes[i].Mode, affectedModes[j].Mode)
})
service.Notice(rb, fmt.Sprintf(client.t("Channel %[1]s has %[2]d persistent modes set"), channelName, len(affectedModes)))
for _, modeChange := range affectedModes {
service.Notice(rb, fmt.Sprintf(client.t("Account %[1]s receives mode +%[2]s"), modeChange.Arg, string(modeChange.Mode)))
}
case modes.Add, modes.Remove:
if len(affectedModes) > 0 {
service.Notice(rb, fmt.Sprintf(client.t("Successfully set persistent mode %[1]s on %[2]s"), strings.Join([]string{string(change.Op), string(change.Mode)}, ""), change.Arg))
// #729: apply change to current membership
for _, member := range channel.Members() {
if member.Account() == change.Arg {
// applyModeToMember takes the nickname, not the account name,
// so translate:
modeChange := change
modeChange.Arg = member.Nick()
applied, modeChange := channel.applyModeToMember(client, modeChange, rb)
if applied {
announceCmodeChanges(channel, modes.ModeChanges{modeChange}, server.name, "*", "", false, rb)
}
}
}
} else {
service.Notice(rb, client.t("No changes were made"))
}
}
}
func csOpHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
channelInfo := server.channels.Get(params[0])
if channelInfo == nil {
service.Notice(rb, client.t("Channel does not exist"))
return
}
channelName := channelInfo.Name()
founder := channelInfo.Founder()
clientAccount := client.Account()
if clientAccount == "" {
service.Notice(rb, client.t("You're not logged into an account"))
return
}
var target *Client
if len(params) > 1 {
target = server.clients.Get(params[1])
if target == nil {
service.Notice(rb, client.t("Could not find given client"))
return
}
} else {
target = client
}
var givenMode modes.Mode
if target == client {
if clientAccount == founder {
givenMode = modes.ChannelFounder
} else {
givenMode = channelInfo.getAmode(clientAccount)
if givenMode == modes.Mode(0) {
service.Notice(rb, client.t("You don't have any stored privileges on that channel"))
return
}
}
} else {
if clientAccount == founder {
givenMode = modes.ChannelOperator
} else {
service.Notice(rb, client.t("Only the channel founder can do this"))
return
}
}
applied, change := channelInfo.applyModeToMember(client,
modes.ModeChange{Mode: givenMode,
Op: modes.Add,
Arg: target.NickCasefolded(),
},
rb)
if applied {
announceCmodeChanges(channelInfo, modes.ModeChanges{change}, server.name, "*", "", false, rb)
}
service.Notice(rb, client.t("Successfully granted operator privileges"))
tnick := target.Nick()
server.logger.Info("services", fmt.Sprintf("Client %s op'd [%s] in channel %s", client.Nick(), tnick, channelName))
server.snomasks.Send(sno.LocalChannels, fmt.Sprintf(ircfmt.Unescape("Client $c[grey][$r%s$c[grey]] CS OP'd $c[grey][$r%s$c[grey]] in channel $c[grey][$r%s$c[grey]]"), client.NickMaskString(), tnick, channelName))
}
func csDeopHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
channel := server.channels.Get(params[0])
if channel == nil {
service.Notice(rb, client.t("Channel does not exist"))
return
}
if !channel.hasClient(client) {
service.Notice(rb, client.t("You're not on that channel"))
return
}
var target *Client
if len(params) > 1 {
target = server.clients.Get(params[1])
if target == nil {
service.Notice(rb, client.t("Could not find given client"))
return
}
} else {
target = client
}
present, _, cumodes := channel.ClientStatus(target)
if !present || len(cumodes) == 0 {
service.Notice(rb, client.t("Target has no privileges to remove"))
return
}
tnick := target.Nick()
modeChanges := make(modes.ModeChanges, len(cumodes))
for i, mode := range cumodes {
modeChanges[i] = modes.ModeChange{
Mode: mode,
Op: modes.Remove,
Arg: tnick,
}
}
// use the user's own permissions for the check, then announce
// the changes as coming from chanserv
applied := channel.ApplyChannelModeChanges(client, false, modeChanges, rb)
details := client.Details()
isBot := client.HasMode(modes.Bot)
announceCmodeChanges(channel, applied, details.nickMask, details.accountName, details.account, isBot, rb)
if len(applied) == 0 {
return
}
service.Notice(rb, client.t("Successfully removed operator privileges"))
}
func csRegisterHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
if server.Config().Channels.Registration.OperatorOnly && !client.HasRoleCapabs("chanreg") {
service.Notice(rb, client.t("Channel registration is restricted to server operators"))
return
}
channelName := params[0]
channelInfo := server.channels.Get(channelName)
if channelInfo == nil {
service.Notice(rb, client.t("No such channel"))
return
}
if !channelInfo.ClientIsAtLeast(client, modes.ChannelOperator) {
service.Notice(rb, client.t("You must be an oper on the channel to register it"))
return
}
account := client.Account()
if !checkChanLimit(service, client, rb) {
return
}
// this provides the synchronization that allows exactly one registration of the channel:
err := server.channels.SetRegistered(channelName, account)
if err != nil {
service.Notice(rb, err.Error())
return
}
service.Notice(rb, fmt.Sprintf(client.t("Channel %s successfully registered"), channelName))
server.logger.Info("services", fmt.Sprintf("Client %s registered channel %s", client.Nick(), channelName))
server.snomasks.Send(sno.LocalChannels, fmt.Sprintf(ircfmt.Unescape("Channel registered $c[grey][$r%s$c[grey]] by $c[grey][$r%s$c[grey]]"), channelName, client.nickMaskString))
// give them founder privs
applied, change := channelInfo.applyModeToMember(client,
modes.ModeChange{
Mode: modes.ChannelFounder,
Op: modes.Add,
Arg: client.NickCasefolded(),
},
rb)
if applied {
announceCmodeChanges(channelInfo, modes.ModeChanges{change}, service.prefix, "*", "", false, rb)
}
}
// check whether a client has already registered too many channels
func checkChanLimit(service *ircService, client *Client, rb *ResponseBuffer) (ok bool) {
account := client.Account()
channelsAlreadyRegistered := client.server.channels.ChannelsForAccount(account)
ok = len(channelsAlreadyRegistered) < client.server.Config().Channels.Registration.MaxChannelsPerAccount || client.HasRoleCapabs("chanreg")
if !ok {
service.Notice(rb, client.t("You have already registered the maximum number of channels; try dropping some with /CS UNREGISTER"))
}
return
}
func csPrivsCheck(service *ircService, channel RegisteredChannel, client *Client, rb *ResponseBuffer) (success bool) {
founder := channel.Founder
if founder == "" {
service.Notice(rb, client.t("That channel is not registered"))
return false
}
if client.HasRoleCapabs("chanreg") {
return true
}
if founder != client.Account() {
service.Notice(rb, client.t("Insufficient privileges"))
return false
}
return true
}
func csUnregisterHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
channelName := params[0]
var verificationCode string
if len(params) > 1 {
verificationCode = params[1]
}
channel := server.channels.Get(channelName)
if channel == nil {
service.Notice(rb, client.t("No such channel"))
return
}
info := channel.exportSummary()
channelKey := channel.NameCasefolded()
if !csPrivsCheck(service, info, client, rb) {
return
}
expectedCode := utils.ConfirmationCode(info.Name, info.RegisteredAt)
if expectedCode != verificationCode {
service.Notice(rb, ircfmt.Unescape(client.t("$bWarning: unregistering this channel will remove all stored channel attributes.$b")))
service.Notice(rb, fmt.Sprintf(client.t("To confirm, run this command: %s"), fmt.Sprintf("/CS UNREGISTER %s %s", channelKey, expectedCode)))
return
}
server.channels.SetUnregistered(channelKey, info.Founder)
service.Notice(rb, fmt.Sprintf(client.t("Channel %s is now unregistered"), channelKey))
}
func csClearHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
channel := server.channels.Get(params[0])
if channel == nil {
service.Notice(rb, client.t("Channel does not exist"))
return
}
if !csPrivsCheck(service, channel.exportSummary(), client, rb) {
return
}
switch strings.ToLower(params[1]) {
case "access":
channel.resetAccess()
service.Notice(rb, client.t("Successfully reset channel access"))
case "users":
for _, target := range channel.Members() {
if target != client {
channel.Kick(client, target, "Cleared by ChanServ", rb, true)
}
}
default:
service.Notice(rb, client.t("Invalid parameters"))
}
}
func csTransferHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
if strings.ToLower(params[0]) == "accept" {
processTransferAccept(service, client, params[1], rb)
return
}
chname := params[0]
channel := server.channels.Get(chname)
if channel == nil {
service.Notice(rb, client.t("Channel does not exist"))
return
}
regInfo := channel.exportSummary()
chname = regInfo.Name
account := client.Account()
isFounder := account != "" && account == regInfo.Founder
oper := client.Oper()
hasPrivs := oper.HasRoleCapab("chanreg")
if !isFounder && !hasPrivs {
service.Notice(rb, client.t("Insufficient privileges"))
return
}
target := params[1]
targetAccount, err := server.accounts.LoadAccount(params[1])
if err != nil {
service.Notice(rb, client.t("Account does not exist"))
return
}
if targetAccount.NameCasefolded != account {
expectedCode := utils.ConfirmationCode(regInfo.Name, regInfo.RegisteredAt)
codeValidated := 2 < len(params) && params[2] == expectedCode
if !codeValidated {
service.Notice(rb, ircfmt.Unescape(client.t("$bWarning: you are about to transfer control of your channel to another user.$b")))
service.Notice(rb, fmt.Sprintf(client.t("To confirm your channel transfer, type: /CS TRANSFER %[1]s %[2]s %[3]s"), chname, target, expectedCode))
return
}
}
if !isFounder {
message := fmt.Sprintf("Operator %s ran CS TRANSFER on %s to account %s", oper.Name, chname, target)
server.snomasks.Send(sno.LocalOpers, message)
server.logger.Info("opers", message)
}
status, err := channel.Transfer(client, target, hasPrivs)
if err == nil {
switch status {
case channelTransferComplete:
service.Notice(rb, fmt.Sprintf(client.t("Successfully transferred channel %[1]s to account %[2]s"), chname, target))
case channelTransferPending:
sendTransferPendingNotice(service, server, target, chname)
service.Notice(rb, fmt.Sprintf(client.t("Transfer of channel %[1]s to account %[2]s succeeded, pending acceptance"), chname, target))
case channelTransferCancelled:
service.Notice(rb, fmt.Sprintf(client.t("Cancelled pending transfer of channel %s"), chname))
}
} else {
switch err {
case errChannelNotOwnedByAccount:
service.Notice(rb, client.t("You don't own that channel"))
default:
service.Notice(rb, client.t("Could not transfer channel"))
}
}
}
func sendTransferPendingNotice(service *ircService, server *Server, account, chname string) {
clients := server.accounts.AccountToClients(account)
if len(clients) == 0 {
return
}
var client *Client
for _, candidate := range clients {
client = candidate
if candidate.NickCasefolded() == candidate.Account() {
break // prefer the login where the nick is the account
}
}
client.Send(nil, service.prefix, "NOTICE", client.Nick(), fmt.Sprintf(client.t("You have been offered ownership of channel %[1]s. To accept, /CS TRANSFER ACCEPT %[1]s"), chname))
}
func processTransferAccept(service *ircService, client *Client, chname string, rb *ResponseBuffer) {
channel := client.server.channels.Get(chname)
if channel == nil {
service.Notice(rb, client.t("Channel does not exist"))
return
}
if !checkChanLimit(service, client, rb) {
return
}
switch channel.AcceptTransfer(client) {
case nil:
service.Notice(rb, fmt.Sprintf(client.t("Successfully accepted ownership of channel %s"), channel.Name()))
case errChannelTransferNotOffered:
service.Notice(rb, fmt.Sprintf(client.t("You weren't offered ownership of channel %s"), channel.Name()))
default:
service.Notice(rb, fmt.Sprintf(client.t("Could not accept ownership of channel %s"), channel.Name()))
}
}
func csPurgeHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
oper := client.Oper()
if oper == nil {
return // should be impossible because you need oper capabs for this
}
switch strings.ToLower(params[0]) {
case "add":
csPurgeAddHandler(service, client, params[1:], oper.Name, rb)
case "del", "remove":
csPurgeDelHandler(service, client, params[1:], oper.Name, rb)
case "list":
csPurgeListHandler(service, client, rb)
default:
service.Notice(rb, client.t("Invalid parameters"))
}
}
func csPurgeAddHandler(service *ircService, client *Client, params []string, operName string, rb *ResponseBuffer) {
if len(params) == 0 {
service.Notice(rb, client.t("Invalid parameters"))
return
}
chname := params[0]
params = params[1:]
channel := client.server.channels.Get(chname) // possibly nil
var ctime time.Time
if channel != nil {
chname = channel.Name()
ctime = channel.Ctime()
}
code := utils.ConfirmationCode(chname, ctime)
if len(params) == 0 || params[0] != code {
service.Notice(rb, ircfmt.Unescape(client.t("$bWarning: you are about to empty this channel and remove it from the server.$b")))
service.Notice(rb, fmt.Sprintf(client.t("To confirm, run this command: %s"), fmt.Sprintf("/CS PURGE ADD %s %s", chname, code)))
return
}
params = params[1:]
var reason string
if 1 < len(params) {
reason = params[1]
}
purgeRecord := ChannelPurgeRecord{
Oper: operName,
PurgedAt: time.Now().UTC(),
Reason: reason,
}
switch client.server.channels.Purge(chname, purgeRecord) {
case nil:
if channel != nil { // channel need not exist to be purged
for _, target := range channel.Members() {
channel.Kick(client, target, "Cleared by ChanServ", rb, true)
}
}
service.Notice(rb, fmt.Sprintf(client.t("Successfully purged channel %s from the server"), chname))
client.server.snomasks.Send(sno.LocalChannels, fmt.Sprintf("Operator %s purged channel %s [reason: %s]", operName, chname, reason))
case errInvalidChannelName:
service.Notice(rb, fmt.Sprintf(client.t("Can't purge invalid channel %s"), chname))
default:
service.Notice(rb, client.t("An error occurred"))
}
}
func csPurgeDelHandler(service *ircService, client *Client, params []string, operName string, rb *ResponseBuffer) {
if len(params) == 0 {
service.Notice(rb, client.t("Invalid parameters"))
return
}
chname := params[0]
switch client.server.channels.Unpurge(chname) {
case nil:
service.Notice(rb, fmt.Sprintf(client.t("Successfully unpurged channel %s from the server"), chname))
client.server.snomasks.Send(sno.LocalChannels, fmt.Sprintf("Operator %s removed purge of channel %s", operName, chname))
case errNoSuchChannel:
service.Notice(rb, fmt.Sprintf(client.t("Channel %s wasn't previously purged from the server"), chname))
default:
service.Notice(rb, client.t("An error occurred"))
}
}
func csPurgeListHandler(service *ircService, client *Client, rb *ResponseBuffer) {
l := client.server.channels.ListPurged()
service.Notice(rb, fmt.Sprintf(client.t("There are %d purged channel(s)."), len(l)))
for i, c := range l {
service.Notice(rb, fmt.Sprintf("%d: %s", i+1, c))
}
}
func csListHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
var searchRegex *regexp.Regexp
if len(params) > 0 {
var err error
searchRegex, err = regexp.Compile(params[0])
if err != nil {
service.Notice(rb, client.t("Invalid regex"))
return
}
}
service.Notice(rb, ircfmt.Unescape(client.t("*** $bChanServ LIST$b ***")))
channels := server.channels.AllRegisteredChannels()
for _, channel := range channels {
if searchRegex == nil || searchRegex.MatchString(channel) {
service.Notice(rb, fmt.Sprintf(" %s", channel))
}
}
service.Notice(rb, ircfmt.Unescape(client.t("*** $bEnd of ChanServ LIST$b ***")))
}
func csInfoHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
if len(params) == 0 {
// #765
listRegisteredChannels(service, client.Account(), rb)
return
}
chname, err := CasefoldChannel(params[0])
if err != nil {
service.Notice(rb, client.t("Invalid channel name"))
return
}
// purge status
if client.HasRoleCapabs("chanreg") {
purgeRecord, err := server.channels.LoadPurgeRecord(chname)
if err == nil {
service.Notice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname))
service.Notice(rb, fmt.Sprintf(client.t("Purged by operator: %s"), purgeRecord.Oper))
service.Notice(rb, fmt.Sprintf(client.t("Purged at: %s"), purgeRecord.PurgedAt.Format(time.RFC1123)))
if purgeRecord.Reason != "" {
service.Notice(rb, fmt.Sprintf(client.t("Purge reason: %s"), purgeRecord.Reason))
}
}
} else {
if server.channels.IsPurged(chname) {
service.Notice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname))
}
}
var chinfo RegisteredChannel
channel := server.channels.Get(params[0])
if channel != nil {
chinfo = channel.exportSummary()
}
// channel exists but is unregistered, or doesn't exist:
if chinfo.Founder == "" {
service.Notice(rb, fmt.Sprintf(client.t("Channel %s is not registered"), chname))
return
}
service.Notice(rb, fmt.Sprintf(client.t("Channel %s is registered"), chinfo.Name))
service.Notice(rb, fmt.Sprintf(client.t("Founder: %s"), chinfo.Founder))
service.Notice(rb, fmt.Sprintf(client.t("Registered at: %s"), chinfo.RegisteredAt.Format(time.RFC1123)))
}
func displayChannelSetting(service *ircService, settingName string, settings ChannelSettings, client *Client, rb *ResponseBuffer) {
config := client.server.Config()
switch strings.ToLower(settingName) {
case "history":
effectiveValue := historyEnabled(config.History.Persistent.RegisteredChannels, settings.History)
service.Notice(rb, fmt.Sprintf(client.t("The stored channel history setting is: %s"), historyStatusToString(settings.History)))
service.Notice(rb, fmt.Sprintf(client.t("Given current server settings, the channel history setting is: %s"), historyStatusToString(effectiveValue)))
case "query-cutoff":
effectiveValue := settings.QueryCutoff
if effectiveValue == HistoryCutoffDefault {
effectiveValue = config.History.Restrictions.queryCutoff
}
service.Notice(rb, fmt.Sprintf(client.t("The stored channel history query cutoff setting is: %s"), historyCutoffToString(settings.QueryCutoff)))
service.Notice(rb, fmt.Sprintf(client.t("Given current server settings, the channel history query cutoff setting is: %s"), historyCutoffToString(effectiveValue)))
default:
service.Notice(rb, client.t("Invalid params"))
}
}
func csGetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
chname, setting := params[0], params[1]
channel := server.channels.Get(chname)
if channel == nil {
service.Notice(rb, client.t("No such channel"))
return
}
info := channel.exportSummary()
if !csPrivsCheck(service, info, client, rb) {
return
}
displayChannelSetting(service, setting, channel.Settings(), client, rb)
}
func csSetHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
chname, setting, value := params[0], params[1], params[2]
channel := server.channels.Get(chname)
if channel == nil {
service.Notice(rb, client.t("No such channel"))
return
}
info := channel.exportSummary()
if !csPrivsCheck(service, info, client, rb) {
return
}
settings := channel.Settings()
var err error
switch strings.ToLower(setting) {
case "history":
settings.History, err = historyStatusFromString(value)
if err != nil {
err = errInvalidParams
break
}
channel.SetSettings(settings)
channel.resizeHistory(server.Config())
case "query-cutoff":
settings.QueryCutoff, err = historyCutoffFromString(value)
if err != nil {
err = errInvalidParams
break
}
channel.SetSettings(settings)
}
switch err {
case nil:
service.Notice(rb, client.t("Successfully changed the channel settings"))
displayChannelSetting(service, setting, settings, client, rb)
case errInvalidParams:
service.Notice(rb, client.t("Invalid parameters"))
default:
server.logger.Error("internal", "CS SET error:", err.Error())
service.Notice(rb, client.t("An error occurred"))
}
}
func csHowToBanHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) {
success := false
defer func() {
if success {
service.Notice(rb, client.t("Note that if the user is currently in the channel, you must /KICK them after you ban them"))
}
}()
chname, nick := params[0], params[1]
channel := server.channels.Get(chname)
if channel == nil {
service.Notice(rb, client.t("No such channel"))
return
}
if !(channel.ClientIsAtLeast(client, modes.ChannelOperator) || client.HasRoleCapabs("samode")) {
service.Notice(rb, client.t("Insufficient privileges"))
return
}
var details WhoWas
target := server.clients.Get(nick)
if target == nil {
whowasList := server.whoWas.Find(nick, 1)
if len(whowasList) == 0 {
service.Notice(rb, client.t("No such nick"))
return
}
service.Notice(rb, fmt.Sprintf(client.t("Warning: %s is not currently connected to the server. Using WHOWAS data, which may be inaccurate:"), nick))
details = whowasList[0]
} else {
details = target.Details().WhoWas
}
if details.account != "" {
if channel.getAmode(details.account) != modes.Mode(0) {
service.Notice(rb, fmt.Sprintf(client.t("Warning: account %s currently has a persistent channel privilege granted with CS AMODE. If this mode is not removed, bans will not be respected"), details.accountName))
return
} else if details.account == channel.Founder() {
service.Notice(rb, fmt.Sprintf(client.t("Warning: account %s is the channel founder and cannot be banned"), details.accountName))
return
}
}
config := server.Config()
if !config.Server.Cloaks.EnabledForAlwaysOn {
service.Notice(rb, client.t("Warning: server.ip-cloaking.enabled-for-always-on is disabled. This reduces the precision of channel bans."))
}
if details.account != "" {
if config.Accounts.NickReservation.ForceNickEqualsAccount || target.AlwaysOn() {
service.Notice(rb, fmt.Sprintf(client.t("User %[1]s is authenticated and can be banned by nickname: /MODE %[2]s +b %[3]s!*@*"), details.nick, channel.Name(), details.nick))
success = true
return
}
}
ban := fmt.Sprintf("*!*@%s", strings.ToLower(details.hostname))
banRe, err := utils.CompileGlob(ban, false)
if err != nil {
server.logger.Error("internal", "couldn't compile ban regex", ban, err.Error())
service.Notice(rb, "An error occurred")
return
}
var collateralDamage []string
for _, mcl := range channel.Members() {
if mcl != target && banRe.MatchString(mcl.NickMaskCasefolded()) {
collateralDamage = append(collateralDamage, mcl.Nick())
}
}
service.Notice(rb, fmt.Sprintf(client.t("User %[1]s can be banned by hostname: /MODE %[2]s +b %[3]s"), details.nick, channel.Name(), ban))
success = true
if len(collateralDamage) != 0 {
service.Notice(rb, fmt.Sprintf(client.t("Warning: this ban will affect %d other users:"), len(collateralDamage)))
for _, line := range utils.BuildTokenLines(maxLastArgLength, collateralDamage, " ") {
service.Notice(rb, line)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,299 +1,116 @@
// Copyright (c) 2012-2014 Jeremy Latt
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
// Copyright (c) 2016- Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package irc
import (
"errors"
"fmt"
"log"
"regexp"
"strings"
"sync"
"github.com/ergochat/ergo/irc/caps"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils"
"github.com/DanielOaks/girc-go/ircmatch"
)
// ClientManager keeps track of clients by nick, enforcing uniqueness of casefolded nicks
type ClientManager struct {
sync.RWMutex // tier 2
byNick map[string]*Client
bySkeleton map[string]*Client
var (
ErrNickMissing = errors.New("nick missing")
ErrNicknameInUse = errors.New("nickname in use")
ErrNicknameMismatch = errors.New("nickname mismatch")
)
func ExpandUserHost(userhost string) (expanded string) {
expanded = userhost
// fill in missing wildcards for nicks
//TODO(dan): this would fail with dan@lol, do we want to accommodate that?
if !strings.Contains(expanded, "!") {
expanded += "!*"
}
if !strings.Contains(expanded, "@") {
expanded += "@*"
}
return
}
// Initialize initializes a ClientManager.
func (clients *ClientManager) Initialize() {
clients.byNick = make(map[string]*Client)
clients.bySkeleton = make(map[string]*Client)
type ClientLookupSet struct {
ByNick map[string]*Client
}
// Get retrieves a client from the manager, if they exist.
func (clients *ClientManager) Get(nick string) *Client {
func NewClientLookupSet() *ClientLookupSet {
return &ClientLookupSet{
ByNick: make(map[string]*Client),
}
}
func (clients *ClientLookupSet) Has(nick string) bool {
casefoldedName, err := CasefoldName(nick)
if err == nil {
clients.RLock()
defer clients.RUnlock()
cli := clients.byNick[casefoldedName]
return cli
return false
}
_, exists := clients.ByNick[casefoldedName]
return exists
}
func (clients *ClientLookupSet) Get(nick string) *Client {
casefoldedName, err := CasefoldName(nick)
if err == nil {
return clients.ByNick[casefoldedName]
}
return nil
}
func (clients *ClientManager) removeInternal(client *Client, oldcfnick, oldskeleton string) (err error) {
// requires holding the writable Lock()
if oldcfnick == "*" || oldcfnick == "" {
return errNickMissing
func (clients *ClientLookupSet) Add(client *Client) error {
if !client.HasNick() {
return ErrNickMissing
}
currentEntry, present := clients.byNick[oldcfnick]
if present {
if currentEntry == client {
delete(clients.byNick, oldcfnick)
} else {
// this shouldn't happen, but we can ignore it
client.server.logger.Warning("internal", "clients for nick out of sync", oldcfnick)
err = errNickMissing
}
} else {
err = errNickMissing
if clients.Get(client.nick) != nil {
return ErrNicknameInUse
}
currentEntry, present = clients.bySkeleton[oldskeleton]
if present {
if currentEntry == client {
delete(clients.bySkeleton, oldskeleton)
} else {
client.server.logger.Warning("internal", "clients for skeleton out of sync", oldskeleton)
err = errNickMissing
}
} else {
err = errNickMissing
}
return
clients.ByNick[client.nickCasefolded] = client
return nil
}
// Remove removes a client from the lookup set.
func (clients *ClientManager) Remove(client *Client) error {
clients.Lock()
defer clients.Unlock()
oldcfnick, oldskeleton := client.uniqueIdentifiers()
return clients.removeInternal(client, oldcfnick, oldskeleton)
func (clients *ClientLookupSet) Remove(client *Client) error {
if !client.HasNick() {
return ErrNickMissing
}
if clients.Get(client.nick) != client {
return ErrNicknameMismatch
}
delete(clients.ByNick, client.nickCasefolded)
return nil
}
// SetNick sets a client's nickname, validating it against nicknames in use
// XXX: dryRun validates a client's ability to claim a nick, without
// actually claiming it
func (clients *ClientManager) SetNick(client *Client, session *Session, newNick string, dryRun bool) (setNick string, err error, awayChanged bool) {
config := client.server.Config()
var newCfNick, newSkeleton string
client.stateMutex.RLock()
account := client.account
accountName := client.accountName
settings := client.accountSettings
registered := client.registered
client.stateMutex.RUnlock()
// these restrictions have grandfather exceptions for nicknames registered
// on previous versions of Ergo:
if newNick != accountName {
// can't contain "disfavored" characters like <, or start with a $ because
// it collides with the massmessage mask syntax. '0' conflicts with the use of 0
// as a placeholder in WHOX (#1896):
if strings.ContainsAny(newNick, disfavoredNameCharacters) || strings.HasPrefix(newNick, "$") ||
newNick == "0" {
return "", errNicknameInvalid, false
}
}
// recompute always-on status, because client.alwaysOn is not set for unregistered clients
var alwaysOn, useAccountName bool
if account != "" {
alwaysOn = persistenceEnabled(config.Accounts.Multiclient.AlwaysOn, settings.AlwaysOn)
useAccountName = alwaysOn || config.Accounts.NickReservation.ForceNickEqualsAccount
}
nickIsReserved := false
if useAccountName {
if registered && newNick != accountName {
return "", errNickAccountMismatch, false
}
newNick = accountName
newCfNick = account
newSkeleton, err = Skeleton(newNick)
if err != nil {
return "", errNicknameInvalid, false
}
} else {
newNick = strings.TrimSpace(newNick)
if len(newNick) == 0 {
return "", errNickMissing, false
}
if account == "" && config.Accounts.NickReservation.ForceGuestFormat && !dryRun {
newCfNick, err = CasefoldName(newNick)
if err != nil {
return "", errNicknameInvalid, false
}
if !config.Accounts.NickReservation.guestRegexpFolded.MatchString(newCfNick) {
newNick = strings.Replace(config.Accounts.NickReservation.GuestFormat, "*", newNick, 1)
newCfNick = "" // re-fold it below
}
}
if newCfNick == "" {
newCfNick, err = CasefoldName(newNick)
}
if err != nil {
return "", errNicknameInvalid, false
}
if len(newNick) > config.Limits.NickLen || len(newCfNick) > config.Limits.NickLen {
return "", errNicknameInvalid, false
}
newSkeleton, err = Skeleton(newNick)
if err != nil {
return "", errNicknameInvalid, false
}
if config.isRelaymsgIdentifier(newNick) {
return "", errNicknameInvalid, false
}
if restrictedCasefoldedNicks.Has(newCfNick) || restrictedSkeletons.Has(newSkeleton) {
return "", errNicknameInvalid, false
}
reservedAccount, method := client.server.accounts.EnforcementStatus(newCfNick, newSkeleton)
if method == NickEnforcementStrict && reservedAccount != "" && reservedAccount != account {
// 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
}
}
var bouncerAllowed bool
if config.Accounts.Multiclient.Enabled {
if useAccountName {
bouncerAllowed = true
} else {
if config.Accounts.Multiclient.AllowedByDefault && settings.AllowBouncer != MulticlientDisallowedByUser {
bouncerAllowed = true
} else if settings.AllowBouncer == MulticlientAllowedByUser {
bouncerAllowed = true
}
}
}
clients.Lock()
defer clients.Unlock()
currentClient := clients.byNick[newCfNick]
// the client may just be changing case
if currentClient != nil && currentClient != client {
// these conditions forbid reattaching to an existing session:
if registered || !bouncerAllowed || account == "" || account != currentClient.Account() ||
dryRun || session == nil {
return "", errNicknameInUse, false
}
reattachSuccessful, numSessions, lastSeen, wasAway, nowAway := currentClient.AddSession(session)
if !reattachSuccessful {
return "", errNicknameInUse, false
}
if numSessions == 1 {
invisible := currentClient.HasMode(modes.Invisible)
operator := currentClient.HasMode(modes.Operator)
client.server.stats.AddRegistered(invisible, operator)
}
session.autoreplayMissedSince = lastSeen
// successful reattach!
return newNick, nil, wasAway != nowAway
} else if currentClient == client && currentClient.Nick() == newNick {
return "", errNoop, false
}
// analogous checks for skeletons
skeletonHolder := clients.bySkeleton[newSkeleton]
if skeletonHolder != nil && skeletonHolder != client {
return "", errNicknameInUse, false
}
if nickIsReserved {
return "", errNicknameReserved, false
}
if dryRun {
return "", nil, false
}
formercfnick, formerskeleton := client.uniqueIdentifiers()
if changeSuccess := client.SetNick(newNick, newCfNick, newSkeleton); !changeSuccess {
return "", errClientDestroyed, false
}
clients.removeInternal(client, formercfnick, formerskeleton)
clients.byNick[newCfNick] = client
clients.bySkeleton[newSkeleton] = client
return newNick, nil, false
}
func (clients *ClientManager) AllClients() (result []*Client) {
clients.RLock()
defer clients.RUnlock()
result = make([]*Client, len(clients.byNick))
i := 0
for _, client := range clients.byNick {
result[i] = client
i++
}
return
}
// AllWithCapsNotify returns all sessions that support cap-notify.
func (clients *ClientManager) AllWithCapsNotify() (sessions []*Session) {
clients.RLock()
defer clients.RUnlock()
for _, client := range clients.byNick {
for _, session := range client.Sessions() {
// cap-notify is implicit in cap version 302 and above
if session.capabilities.Has(caps.CapNotify) || 302 <= session.capVersion {
sessions = append(sessions, session)
}
}
}
return
}
// AllWithPushSubscriptions returns all clients that are always-on with an active push subscription.
func (clients *ClientManager) AllWithPushSubscriptions() (result []*Client) {
clients.RLock()
defer clients.RUnlock()
for _, client := range clients.byNick {
if client.hasPushSubscriptions() && client.AlwaysOn() {
result = append(result, client)
}
}
return result
}
// FindAll returns all clients that match the given userhost mask.
func (clients *ClientManager) FindAll(userhost string) (set ClientSet) {
func (clients *ClientLookupSet) AllWithCaps(caps ...Capability) (set ClientSet) {
set = make(ClientSet)
userhost, err := CanonicalizeMaskWildcard(userhost)
var client *Client
for _, client = range clients.ByNick {
// make sure they have all the required caps
for _, Cap := range caps {
if !client.capabilities[Cap] {
continue
}
}
set.Add(client)
}
return set
}
func (clients *ClientLookupSet) FindAll(userhost string) (set ClientSet) {
set = make(ClientSet)
userhost, err := Casefold(ExpandUserHost(userhost))
if err != nil {
return set
}
matcher, err := utils.CompileGlob(userhost, false)
if err != nil {
// not much we can do here
return
}
matcher := ircmatch.MakeMatch(userhost)
clients.RLock()
defer clients.RUnlock()
for _, client := range clients.byNick {
if matcher.MatchString(client.NickMaskCasefolded()) {
for _, client := range clients.ByNick {
if matcher.Match(client.nickMaskCasefolded) {
set.Add(client)
}
}
@ -301,15 +118,117 @@ func (clients *ClientManager) FindAll(userhost string) (set ClientSet) {
return set
}
// Determine the canonical / unfolded form of a nick, if a client matching it
// is present (or always-on).
func (clients *ClientManager) UnfoldNick(cfnick string) (nick string) {
clients.RLock()
c := clients.byNick[cfnick]
clients.RUnlock()
if c != nil {
return c.Nick()
} else {
return cfnick
func (clients *ClientLookupSet) Find(userhost string) *Client {
userhost, err := Casefold(ExpandUserHost(userhost))
if err != nil {
return nil
}
matcher := ircmatch.MakeMatch(userhost)
for _, client := range clients.ByNick {
if matcher.Match(client.nickMaskCasefolded) {
return client
}
}
return nil
}
//
// usermask to regexp
//
//TODO(dan): move this over to generally using glob syntax instead?
// kinda more expected in normal ban/etc masks, though regex is useful (probably as an extban?)
type UserMaskSet struct {
masks map[string]bool
regexp *regexp.Regexp
}
func NewUserMaskSet() *UserMaskSet {
return &UserMaskSet{
masks: make(map[string]bool),
}
}
func (set *UserMaskSet) Add(mask string) bool {
casefoldedMask, err := Casefold(mask)
if err != nil {
log.Println(fmt.Sprintf("ERROR: Could not add mask to usermaskset: [%s]", mask))
return false
}
if set.masks[casefoldedMask] {
return false
}
set.masks[casefoldedMask] = true
set.setRegexp()
return true
}
func (set *UserMaskSet) AddAll(masks []string) (added bool) {
for _, mask := range masks {
if !added && !set.masks[mask] {
added = true
}
set.masks[mask] = true
}
set.setRegexp()
return
}
func (set *UserMaskSet) Remove(mask string) bool {
if !set.masks[mask] {
return false
}
delete(set.masks, mask)
set.setRegexp()
return true
}
func (set *UserMaskSet) Match(userhost string) bool {
if set.regexp == nil {
return false
}
return set.regexp.MatchString(userhost)
}
func (set *UserMaskSet) String() string {
masks := make([]string, len(set.masks))
index := 0
for mask := range set.masks {
masks[index] = mask
index += 1
}
return strings.Join(masks, " ")
}
// Generate a regular expression from the set of user mask
// strings. Masks are split at the two types of wildcards, `*` and
// `?`. All the pieces are meta-escaped. `*` is replaced with `.*`,
// the regexp equivalent. Likewise, `?` is replaced with `.`. The
// parts are re-joined and finally all masks are joined into a big
// or-expression.
func (set *UserMaskSet) setRegexp() {
if len(set.masks) == 0 {
set.regexp = nil
return
}
maskExprs := make([]string, len(set.masks))
index := 0
for mask := range set.masks {
manyParts := strings.Split(mask, "*")
manyExprs := make([]string, len(manyParts))
for mindex, manyPart := range manyParts {
oneParts := strings.Split(manyPart, "?")
oneExprs := make([]string, len(oneParts))
for oindex, onePart := range oneParts {
oneExprs[oindex] = regexp.QuoteMeta(onePart)
}
manyExprs[mindex] = strings.Join(oneExprs, ".")
}
maskExprs[index] = strings.Join(manyExprs, ".*")
}
expr := "^" + strings.Join(maskExprs, "|") + "$"
set.regexp, _ = regexp.Compile(expr)
}

View File

@ -1,133 +0,0 @@
// Copyright (c) 2019 Shivaram Lingamneni
// released under the MIT license
package irc
import (
"fmt"
"testing"
"github.com/ergochat/ergo/irc/languages"
"github.com/ergochat/ergo/irc/utils"
)
func TestGenerateBatchID(t *testing.T) {
var session Session
s := make(utils.HashSet[string])
count := 100000
for i := 0; i < count; i++ {
s.Add(session.generateBatchID())
}
if len(s) != count {
t.Error("duplicate batch ID detected")
}
}
func BenchmarkGenerateBatchID(b *testing.B) {
var session Session
for i := 0; i < b.N; i++ {
session.generateBatchID()
}
}
func BenchmarkNames(b *testing.B) {
channelSize := 1024
server := &Server{
name: "ergo.test",
}
lm, err := languages.NewManager(false, "", "")
if err != nil {
b.Fatal(err)
}
server.config.Store(&Config{
languageManager: lm,
})
for i := 0; i < b.N; i++ {
channel := &Channel{
name: "#test",
nameCasefolded: "#test",
server: server,
members: make(MemberSet),
}
for j := 0; j < channelSize; j++ {
nick := fmt.Sprintf("client_%d", j)
client := &Client{
server: server,
nick: nick,
nickCasefolded: nick,
}
channel.members.Add(client)
channel.regenerateMembersCache()
session := &Session{
client: client,
}
rb := NewResponseBuffer(session)
channel.Names(client, rb)
if len(rb.messages) < 2 {
b.Fatalf("not enough messages: %d", len(rb.messages))
}
// to inspect the messages: line, _ := rb.messages[0].Line()
}
}
}
func TestUserMasks(t *testing.T) {
var um UserMaskSet
if um.Match("horse_!user@tor-network.onion") {
t.Error("bad match")
}
um.Add("_!*@*", "x", "x")
if !um.Match("_!user@tor-network.onion") {
t.Error("failure to match")
}
if um.Match("horse_!user@tor-network.onion") {
t.Error("bad match")
}
um.Add("beer*!*@*", "x", "x")
if !um.Match("beergarden!user@tor-network.onion") {
t.Error("failure to match")
}
if um.Match("horse_!user@tor-network.onion") {
t.Error("bad match")
}
um.Add("horse*!user@*", "x", "x")
if !um.Match("horse_!user@tor-network.onion") {
t.Error("failure to match")
}
}
func TestWhoFields(t *testing.T) {
var w whoxFields
if w.Has('a') {
t.Error("zero value of whoxFields must be empty")
}
w = w.Add('a')
if !w.Has('a') {
t.Error("failed to set and get")
}
if w.Has('A') {
t.Error("false positive")
}
if w.Has('o') {
t.Error("false positive")
}
w = w.Add('🐬')
if w.Has('🐬') {
t.Error("should not be able to set invalid who field")
}
w = w.Add('o')
if !w.Has('o') {
t.Error("failed to set and get")
}
w = w.Add('z')
if !w.Has('z') {
t.Error("failed to set and get")
}
}

126
irc/clientsocket.go Normal file
View File

@ -0,0 +1,126 @@
// Copyright (c) 2016- Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package irc
import (
"fmt"
"net"
"strings"
"github.com/DanielOaks/girc-go/ircmsg"
)
// ClientSocket listens to a socket using the IRC protocol, processes events,
// and also sends IRC lines out of that socket.
type ClientSocket struct {
receiveLines chan string
ReceiveEvents chan Message
SendLines chan string
socket Socket
client Client
}
// NewClientSocket returns a new ClientSocket.
func NewClientSocket(conn net.Conn, client Client) ClientSocket {
return ClientSocket{
receiveLines: make(chan string),
ReceiveEvents: make(chan Message),
SendLines: make(chan string),
socket: NewSocket(conn),
client: client,
}
}
// Start creates and starts running the necessary event loops.
func (cs *ClientSocket) Start() {
go cs.RunEvents()
go cs.RunSocketSender()
go cs.RunSocketListener()
}
// RunEvents handles received IRC lines and processes incoming commands.
func (cs *ClientSocket) RunEvents() {
var exiting bool
var line string
for !exiting {
select {
case line = <-cs.receiveLines:
if line != "" {
fmt.Println("<- ", strings.TrimRight(line, "\r\n"))
exiting = cs.processIncomingLine(line)
}
}
}
// empty the receiveLines queue
cs.socket.Close()
select {
case <-cs.receiveLines:
// empty
default:
// empty
}
}
// RunSocketSender sends lines to the IRC socket.
func (cs *ClientSocket) RunSocketSender() {
var err error
var line string
for {
line = <-cs.SendLines
err = cs.socket.Write(line)
fmt.Println(" ->", strings.TrimRight(line, "\r\n"))
if err != nil {
break
}
}
}
// RunSocketListener receives lines from the IRC socket.
func (cs *ClientSocket) RunSocketListener() {
var errConn error
var line string
for {
line, errConn = cs.socket.Read()
cs.receiveLines <- line
if errConn != nil {
break
}
}
if !cs.socket.Closed {
cs.Send(nil, "", "ERROR", "Closing connection")
cs.socket.Close()
}
}
// Send sends an IRC line to the listener.
func (cs *ClientSocket) Send(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) error {
ircmsg := ircmsg.MakeMessage(tags, prefix, command, params...)
line, err := ircmsg.Line()
if err != nil {
return err
}
cs.SendLines <- line
return nil
}
// processIncomingLine splits and handles the given command line.
// Returns true if client is exiting (sent a QUIT command, etc).
func (cs *ClientSocket) processIncomingLine(line string) bool {
msg, err := ircmsg.ParseLine(line)
if err != nil {
cs.Send(nil, "", "ERROR", "Your client sent a malformed line")
return true
}
command, canBeParsed := Commands[msg.Command]
if canBeParsed {
return command.Run(cs.client.server, &cs.client, msg)
}
//TODO(dan): This is an error+disconnect purely for reasons of testing.
// Later it may be downgraded to not-that-bad.
cs.Send(nil, "", "ERROR", fmt.Sprintf("Your client sent a command that could not be parsed [%s]", msg.Command))
return true
}

View File

@ -1,136 +0,0 @@
// Copyright (c) 2019 Shivaram Lingamneni
// released under the MIT license
package cloaks
import (
"net"
"reflect"
"testing"
)
func assertEqual(supplied, expected interface{}, t *testing.T) {
if !reflect.DeepEqual(supplied, expected) {
t.Errorf("expected %v but got %v", expected, supplied)
}
}
func easyParseIP(ipstr string) (result net.IP) {
result = net.ParseIP(ipstr)
if result == nil {
panic(ipstr)
}
return
}
func cloakConfForTesting() CloakConfig {
config := CloakConfig{
Enabled: true,
Netname: "oragono",
secret: "_BdVPWB5sray7McbFmeuJL996yaLgG4l9tEyficGXKg",
CidrLenIPv4: 32,
CidrLenIPv6: 64,
NumBits: 80,
}
config.Initialize()
return config
}
func TestCloakDeterminism(t *testing.T) {
config := cloakConfForTesting()
v4ip := easyParseIP("8.8.8.8").To4()
assertEqual(config.ComputeCloak(v4ip), "d2z5guriqhzwazyr.oragono", t)
// use of the 4-in-6 mapping should not affect the cloak
v6mappedIP := v4ip.To16()
assertEqual(config.ComputeCloak(v6mappedIP), "d2z5guriqhzwazyr.oragono", t)
v6ip := easyParseIP("2001:0db8::1")
assertEqual(config.ComputeCloak(v6ip), "w7ren6nxii6f3i3d.oragono", t)
// same CIDR, so same cloak:
v6ipsamecidr := easyParseIP("2001:0db8::2")
assertEqual(config.ComputeCloak(v6ipsamecidr), "w7ren6nxii6f3i3d.oragono", t)
v6ipdifferentcidr := easyParseIP("2001:0db9::1")
// different CIDR, different cloak:
assertEqual(config.ComputeCloak(v6ipdifferentcidr), "ccmptyrjwsxv4f4d.oragono", t)
// cloak values must be sensitive to changes in the secret key
config.SetSecret("HJcXK4lLawxBE4-9SIdPji_21YiL3N5r5f5-SPNrGVY")
assertEqual(config.ComputeCloak(v4ip), "4khy3usk8mfu42pe.oragono", t)
assertEqual(config.ComputeCloak(v6mappedIP), "4khy3usk8mfu42pe.oragono", t)
assertEqual(config.ComputeCloak(v6ip), "mxpk3c83vdxkek9j.oragono", t)
assertEqual(config.ComputeCloak(v6ipsamecidr), "mxpk3c83vdxkek9j.oragono", t)
}
func TestCloakShortv4Cidr(t *testing.T) {
config := CloakConfig{
Enabled: true,
Netname: "oragono",
secret: "_BdVPWB5sray7McbFmeuJL996yaLgG4l9tEyficGXKg",
CidrLenIPv4: 24,
CidrLenIPv6: 64,
NumBits: 60,
}
config.Initialize()
v4ip := easyParseIP("8.8.8.8")
assertEqual(config.ComputeCloak(v4ip), "3cay3zc72tnui.oragono", t)
v4ipsamecidr := easyParseIP("8.8.8.9")
assertEqual(config.ComputeCloak(v4ipsamecidr), "3cay3zc72tnui.oragono", t)
}
func TestCloakZeroBits(t *testing.T) {
config := cloakConfForTesting()
config.NumBits = 0
config.Netname = "example.com"
config.Initialize()
v4ip := easyParseIP("8.8.8.8").To4()
assertEqual(config.ComputeCloak(v4ip), "example.com", t)
}
func TestCloakDisabled(t *testing.T) {
config := cloakConfForTesting()
config.Enabled = false
v4ip := easyParseIP("8.8.8.8").To4()
assertEqual(config.ComputeCloak(v4ip), "", t)
}
func BenchmarkCloaks(b *testing.B) {
config := cloakConfForTesting()
v6ip := easyParseIP("2001:0db8::1")
b.ResetTimer()
for i := 0; i < b.N; i++ {
config.ComputeCloak(v6ip)
}
}
func TestAccountCloak(t *testing.T) {
config := cloakConfForTesting()
// just assert that we get all distinct values
assertEqual(config.ComputeAccountCloak("shivaram"), "8yu8kunudb45ztxm.oragono", t)
assertEqual(config.ComputeAccountCloak("dolph🐬n"), "hhgeqsvzeagv3wjw.oragono", t)
assertEqual(config.ComputeAccountCloak("SHIVARAM"), "bgx32x4r7qzih4uh.oragono", t)
assertEqual(config.ComputeAccountCloak("ed"), "j5autmgxtdjdyzf4.oragono", t)
}
func TestAccountCloakCollisions(t *testing.T) {
config := cloakConfForTesting()
v4ip := easyParseIP("97.97.97.97")
v4cloak := config.ComputeCloak(v4ip)
// "aaaa" is the same bytestring as 97.97.97.97
aaaacloak := config.ComputeAccountCloak("aaaa")
if v4cloak == aaaacloak {
t.Errorf("cloak collision between 97.97.97.97 and aaaa: %s", v4cloak)
}
}
func BenchmarkAccountCloaks(b *testing.B) {
config := cloakConfForTesting()
b.ResetTimer()
for i := 0; i < b.N; i++ {
config.ComputeAccountCloak("shivaram")
}
}

View File

@ -1,95 +0,0 @@
// Copyright (c) 2019 Shivaram Lingamneni
package cloaks
import (
"fmt"
"net"
"crypto/sha3"
"github.com/ergochat/ergo/irc/utils"
)
type CloakConfig struct {
Enabled bool
EnabledForAlwaysOn bool `yaml:"enabled-for-always-on"`
Netname string
CidrLenIPv4 int `yaml:"cidr-len-ipv4"`
CidrLenIPv6 int `yaml:"cidr-len-ipv6"`
NumBits int `yaml:"num-bits"`
LegacySecretValue string `yaml:"secret"`
secret string
numBytes int
ipv4Mask net.IPMask
ipv6Mask net.IPMask
}
func (cloakConfig *CloakConfig) Initialize() {
// sanity checks:
numBits := cloakConfig.NumBits
if 0 == numBits {
numBits = 64
} else if 256 < numBits {
numBits = 256
}
// derived values:
cloakConfig.numBytes = numBits / 8
// round up to the nearest byte
if numBits%8 != 0 {
cloakConfig.numBytes += 1
}
cloakConfig.ipv4Mask = net.CIDRMask(cloakConfig.CidrLenIPv4, 32)
cloakConfig.ipv6Mask = net.CIDRMask(cloakConfig.CidrLenIPv6, 128)
}
func (cloakConfig *CloakConfig) SetSecret(secret string) {
cloakConfig.secret = secret
}
// simple cloaking algorithm: normalize the IP to its CIDR,
// then hash the resulting bytes with a secret key,
// then truncate to the desired length, b32encode, and append the fake TLD.
func (config *CloakConfig) ComputeCloak(ip net.IP) string {
if !config.Enabled {
return ""
} else if config.NumBits == 0 || config.secret == "" {
return config.Netname
}
var masked net.IP
v4ip := ip.To4()
if v4ip != nil {
masked = v4ip.Mask(config.ipv4Mask)
} else {
masked = ip.Mask(config.ipv6Mask)
}
return config.macAndCompose(masked)
}
func (config *CloakConfig) macAndCompose(b []byte) string {
// SHA3(K || M):
// https://crypto.stackexchange.com/questions/17735/is-hmac-needed-for-a-sha-3-based-mac
input := make([]byte, len(config.secret)+len(b))
copy(input, config.secret[:])
copy(input[len(config.secret):], b)
digest := sha3.Sum512(input)
b32digest := utils.B32Encoder.EncodeToString(digest[:config.numBytes])
return fmt.Sprintf("%s.%s", b32digest, config.Netname)
}
func (config *CloakConfig) ComputeAccountCloak(accountName string) string {
// XXX don't bother checking EnabledForAlwaysOn, since if it's disabled,
// we need to use the server name which we don't have
if config.NumBits == 0 || config.secret == "" {
return config.Netname
}
// pad with 16 initial bytes of zeroes, avoiding any possibility of collision
// with a masked IP that could be an input to ComputeCloak:
paddedAccountName := make([]byte, 16+len(accountName))
copy(paddedAccountName[16:], accountName[:])
return config.macAndCompose(paddedAccountName)
}

View File

@ -1,416 +1,201 @@
// Copyright (c) 2012-2014 Jeremy Latt
// Copyright (c) 2014-2015 Edmund Huber
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
// Copyright (c) 2016- Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package irc
import (
"github.com/ergochat/irc-go/ircmsg"
)
import "github.com/DanielOaks/girc-go/ircmsg"
// Command represents a command accepted from a client.
type Command struct {
handler func(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool
usablePreReg bool
allowedInBatch bool // allowed in client-to-server batches
minParams int
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
handler func(server *Server, client *Client, msg ircmsg.IrcMessage) bool
oper bool
usablePreReg bool
leaveClientActive bool // if true, leaves the client active time alone. reversed because we can't default a struct element to True
leaveClientIdle bool
minParams int
}
// Run runs this command with the given client/message.
func (cmd *Command) Run(server *Server, client *Client, session *Session, msg ircmsg.Message) (exiting bool) {
rb := NewResponseBuffer(session)
rb.Label = GetLabel(msg)
exiting = func() bool {
defer rb.Send(true)
if !client.registered && !cmd.usablePreReg {
rb.Add(nil, server.name, ERR_NOTREGISTERED, "*", client.t("You need to register before you can use that command"))
return false
}
if len(cmd.capabs) > 0 && !client.HasRoleCapabs(cmd.capabs...) {
rb.Add(nil, server.name, ERR_NOPRIVILEGES, client.Nick(), client.t("Permission Denied"))
return false
}
if len(msg.Params) < cmd.minParams {
rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, rb.target.t("Not enough parameters"))
return false
}
if session.batch.label != "" && !cmd.allowedInBatch {
rb.Add(nil, server.name, "FAIL", "BATCH", "MULTILINE_INVALID", client.t("Command not allowed during a multiline batch"))
session.EndMultilineBatch("")
return false
}
return cmd.handler(server, client, msg, rb)
}()
func (cmd *Command) Run(server *Server, client *Client, msg ircmsg.IrcMessage) bool {
if !client.registered && !cmd.usablePreReg {
// command silently ignored
return false
}
if cmd.oper && !client.flags[Operator] {
client.Send(nil, server.name, ERR_NOPRIVILEGES, client.nick, "Permission Denied - You're not an IRC operator")
return false
}
if len(msg.Params) < cmd.minParams {
client.Send(nil, server.name, ERR_NEEDMOREPARAMS, client.nick, msg.Command, "Not enough parameters")
return false
}
if !cmd.leaveClientActive {
client.Active()
}
if !cmd.leaveClientIdle {
client.Touch()
}
exiting := cmd.handler(server, client, msg)
// after each command, see if we can send registration to the client
if !exiting && !client.registered {
exiting = server.tryRegister(client, session)
}
if client.registered {
client.Touch(session) // even if `exiting`, we bump the lastSeen timestamp
if !client.registered {
server.tryRegister(client)
}
return exiting
}
// fake handler for unknown commands (see #994: this ensures the response tags are correct)
var unknownCommand = Command{
handler: unknownCommandHandler,
usablePreReg: true,
}
var invalidUtf8Command = Command{
handler: invalidUtf8Handler,
usablePreReg: true,
}
// Commands holds all commands executable by a client connected to us.
var Commands map[string]Command
func init() {
Commands = map[string]Command{
"ACCEPT": {
handler: acceptHandler,
minParams: 1,
},
"AMBIANCE": {
handler: sceneHandler,
minParams: 2,
},
"AUTHENTICATE": {
handler: authenticateHandler,
usablePreReg: true,
minParams: 1,
},
"AWAY": {
handler: awayHandler,
usablePreReg: true,
minParams: 0,
},
"BATCH": {
handler: batchHandler,
minParams: 1,
allowedInBatch: true,
},
"CAP": {
handler: capHandler,
usablePreReg: true,
minParams: 1,
},
"CHATHISTORY": {
handler: chathistoryHandler,
minParams: 4,
},
"DEBUG": {
handler: debugHandler,
minParams: 1,
capabs: []string{"rehash"},
},
"DEFCON": {
handler: defconHandler,
capabs: []string{"defcon"},
},
"DEOPER": {
handler: deoperHandler,
minParams: 0,
},
"DLINE": {
handler: dlineHandler,
minParams: 1,
capabs: []string{"ban"},
},
"EXTJWT": {
handler: extjwtHandler,
minParams: 1,
},
"HELP": {
handler: helpHandler,
minParams: 0,
},
"HELPOP": {
handler: helpHandler,
minParams: 0,
},
"HISTORY": {
handler: historyHandler,
minParams: 1,
},
"INFO": {
handler: infoHandler,
},
"INVITE": {
handler: inviteHandler,
minParams: 2,
},
"ISON": {
handler: isonHandler,
minParams: 1,
},
"ISUPPORT": {
handler: isupportHandler,
usablePreReg: true,
},
"JOIN": {
handler: joinHandler,
minParams: 1,
},
"KICK": {
handler: kickHandler,
minParams: 2,
},
"KILL": {
handler: killHandler,
minParams: 1,
capabs: []string{"kill"},
},
"KLINE": {
handler: klineHandler,
minParams: 1,
capabs: []string{"ban"},
},
"LANGUAGE": {
handler: languageHandler,
usablePreReg: true,
minParams: 1,
},
"LIST": {
handler: listHandler,
minParams: 0,
},
"LUSERS": {
handler: lusersHandler,
minParams: 0,
},
"MARKREAD": {
handler: markReadHandler,
minParams: 0, // send FAIL instead of ERR_NEEDMOREPARAMS
},
"METADATA": {
handler: metadataHandler,
minParams: 2,
usablePreReg: true,
},
"MODE": {
handler: modeHandler,
minParams: 1,
},
"MONITOR": {
handler: monitorHandler,
minParams: 1,
},
"MOTD": {
handler: motdHandler,
minParams: 0,
},
"NAMES": {
handler: namesHandler,
minParams: 0,
},
"NICK": {
handler: nickHandler,
usablePreReg: true,
minParams: 1,
},
"NOTICE": {
handler: messageHandler,
minParams: 2,
allowedInBatch: true,
},
"NPC": {
handler: npcHandler,
minParams: 3,
},
"NPCA": {
handler: npcaHandler,
minParams: 3,
},
"OPER": {
handler: operHandler,
minParams: 1,
},
"PART": {
handler: partHandler,
minParams: 1,
},
"PASS": {
handler: passHandler,
usablePreReg: true,
minParams: 1,
},
"PERSISTENCE": {
handler: persistenceHandler,
minParams: 1,
},
"PING": {
handler: pingHandler,
usablePreReg: true,
minParams: 1,
},
"PONG": {
handler: pongHandler,
usablePreReg: true,
minParams: 1,
},
"PRIVMSG": {
handler: messageHandler,
minParams: 2,
allowedInBatch: true,
},
"RELAYMSG": {
handler: relaymsgHandler,
minParams: 3,
},
"REGISTER": {
handler: registerHandler,
minParams: 3,
usablePreReg: true,
},
"RENAME": {
handler: renameHandler,
minParams: 2,
},
"SAJOIN": {
handler: sajoinHandler,
minParams: 1,
capabs: []string{"sajoin"},
},
"SANICK": {
handler: sanickHandler,
minParams: 2,
capabs: []string{"samode"},
},
"SAMODE": {
handler: modeHandler,
minParams: 1,
capabs: []string{"samode"},
},
"SCENE": {
handler: sceneHandler,
minParams: 2,
},
"SETNAME": {
handler: setnameHandler,
minParams: 1,
},
"SUMMON": {
handler: summonHandler,
},
"TAGMSG": {
handler: messageHandler,
minParams: 1,
},
"QUIT": {
handler: quitHandler,
usablePreReg: true,
minParams: 0,
},
"REDACT": {
handler: redactHandler,
minParams: 2,
},
"REHASH": {
handler: rehashHandler,
minParams: 0,
capabs: []string{"rehash"},
},
"TIME": {
handler: timeHandler,
minParams: 0,
},
"TOPIC": {
handler: topicHandler,
minParams: 1,
},
"UBAN": {
handler: ubanHandler,
minParams: 1,
capabs: []string{"ban"},
},
"UNDLINE": {
handler: unDLineHandler,
minParams: 1,
capabs: []string{"ban"},
},
"UNINVITE": {
handler: inviteHandler,
minParams: 2,
},
"UNKLINE": {
handler: unKLineHandler,
minParams: 1,
capabs: []string{"ban"},
},
"USER": {
handler: userHandler,
usablePreReg: true,
minParams: 4,
},
"USERHOST": {
handler: userhostHandler,
minParams: 1,
},
"USERS": {
handler: usersHandler,
},
"VERIFY": {
handler: verifyHandler,
usablePreReg: true,
minParams: 2,
},
"VERSION": {
handler: versionHandler,
minParams: 0,
},
"WEBIRC": {
handler: webircHandler,
usablePreReg: true,
minParams: 4,
},
"WEBPUSH": {
handler: webpushHandler,
minParams: 2,
},
"WHO": {
handler: whoHandler,
minParams: 1,
},
"WHOIS": {
handler: whoisHandler,
minParams: 1,
},
"WHOWAS": {
handler: whowasHandler,
minParams: 1,
},
"ZNC": {
handler: zncHandler,
minParams: 1,
},
}
initializeServices()
var Commands = map[string]Command{
"AUTHENTICATE": {
handler: authenticateHandler,
usablePreReg: true,
minParams: 1,
},
"AWAY": {
handler: awayHandler,
minParams: 0,
},
"CAP": {
handler: capHandler,
usablePreReg: true,
minParams: 1,
},
"DEBUG": {
handler: debugHandler,
minParams: 1,
},
"HELP": {
handler: helpHandler,
minParams: 0,
},
"INVITE": {
handler: inviteHandler,
minParams: 2,
},
"ISON": {
handler: isonHandler,
minParams: 1,
},
"JOIN": {
handler: joinHandler,
minParams: 1,
},
"KICK": {
handler: kickHandler,
minParams: 2,
},
"KILL": {
handler: killHandler,
minParams: 1,
oper: true,
},
"LIST": {
handler: listHandler,
minParams: 0,
},
"MODE": {
handler: modeHandler,
minParams: 1,
},
"MONITOR": {
handler: monitorHandler,
minParams: 1,
},
"MOTD": {
handler: motdHandler,
minParams: 0,
},
"NAMES": {
handler: namesHandler,
minParams: 0,
},
"NICK": {
handler: nickHandler,
usablePreReg: true,
minParams: 1,
},
"NOTICE": {
handler: noticeHandler,
minParams: 2,
},
"OPER": {
handler: operHandler,
minParams: 2,
},
"PART": {
handler: partHandler,
minParams: 1,
},
"PASS": {
handler: passHandler,
usablePreReg: true,
minParams: 1,
},
"PING": {
handler: pingHandler,
usablePreReg: true,
minParams: 1,
leaveClientActive: true,
},
"PONG": {
handler: pongHandler,
usablePreReg: true,
minParams: 1,
leaveClientActive: true,
},
"PRIVMSG": {
handler: privmsgHandler,
minParams: 2,
},
"SANICK": {
handler: sanickHandler,
minParams: 2,
oper: true,
},
"QUIT": {
handler: quitHandler,
usablePreReg: true,
minParams: 0,
},
"REG": {
handler: regHandler,
minParams: 3,
},
"REHASH": {
handler: rehashHandler,
minParams: 0,
oper: true,
},
"TIME": {
handler: timeHandler,
minParams: 0,
},
"TOPIC": {
handler: topicHandler,
minParams: 1,
},
"USER": {
handler: userHandler,
usablePreReg: true,
minParams: 4,
},
"VERSION": {
handler: versionHandler,
minParams: 0,
},
"WHO": {
handler: whoHandler,
minParams: 0,
},
"WHOIS": {
handler: whoisHandler,
minParams: 1,
},
"WHOWAS": {
handler: whowasHandler,
minParams: 1,
},
}

File diff suppressed because it is too large Load Diff

View File

@ -1,101 +0,0 @@
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package irc
import (
"reflect"
"testing"
)
func TestEnvironmentOverrides(t *testing.T) {
var config Config
config.Server.Compatibility.SendUnprefixedSasl = true
config.History.Enabled = true
defaultUserModes := "+i"
config.Accounts.DefaultUserModes = &defaultUserModes
config.Server.WebSockets.AllowedOrigins = []string{"https://www.ircv3.net"}
config.Server.MOTD = "long.motd.txt" // overwrite this
env := []string{
`USER=shivaram`, // unrelated var
`ORAGONO_USER=oragono`, // this should be ignored as well
`ERGO__NETWORK__NAME=example.com`,
`ORAGONO__SERVER__COMPATIBILITY__FORCE_TRAILING=false`,
`ORAGONO__SERVER__COERCE_IDENT="~user"`,
`ERGO__SERVER__MOTD=short.motd.txt`,
`ORAGONO__ACCOUNTS__NICK_RESERVATION__ENABLED=true`,
`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}`,
}
for _, envPair := range env {
_, _, err := mungeFromEnvironment(&config, envPair)
if err != nil {
t.Errorf("couldn't apply override `%s`: %v", envPair, err)
}
}
if config.Network.Name != "example.com" {
t.Errorf("unexpected value of network.name: %s", config.Network.Name)
}
if config.Server.CoerceIdent != "~user" {
t.Errorf("unexpected value of coerce-ident: %s", config.Server.CoerceIdent)
}
if config.Server.MOTD != "short.motd.txt" {
t.Errorf("unexpected value of motd: %s", config.Server.MOTD)
}
if !config.Accounts.NickReservation.Enabled {
t.Errorf("did not set bool as expected")
}
if !config.Server.Compatibility.SendUnprefixedSasl {
t.Errorf("overwrote unrelated field")
}
if !config.History.Enabled {
t.Errorf("overwrote unrelated field")
}
if !reflect.DeepEqual(config.Server.WebSockets.AllowedOrigins, []string{"https://www.ircv3.net"}) {
t.Errorf("overwrote unrelated field: %#v", config.Server.WebSockets.AllowedOrigins)
}
cloakConf := config.Server.Cloaks
if !(cloakConf.Enabled == true && cloakConf.EnabledForAlwaysOn == true && cloakConf.Netname == "irc" && cloakConf.CidrLenIPv6 == 64) {
t.Errorf("bad value of Cloaks: %#v", config.Server.Cloaks)
}
if *config.Server.Compatibility.ForceTrailing != false {
t.Errorf("couldn't set unset ptr field to false")
}
if *config.Accounts.DefaultUserModes != "+iR" {
t.Errorf("couldn't override pre-set ptr field")
}
}
func TestEnvironmentOverrideErrors(t *testing.T) {
var config Config
config.Server.Compatibility.SendUnprefixedSasl = true
config.History.Enabled = true
invalidEnvs := []string{
`ORAGONO__=asdf`,
`ORAGONO__SERVER__=asdf`,
`ORAGONO__SERVER____=asdf`,
`ORAGONO__NONEXISTENT_KEY=1`,
`ORAGONO__SERVER__NONEXISTENT_KEY=1`,
// invalid yaml:
`ORAGONO__SERVER__IP_CLOAKING__NETNAME="`,
// invalid type:
`ORAGONO__SERVER__IP_CLOAKING__NUM_BITS=asdf`,
`ORAGONO__SERVER__STS=[]`,
// index into non-struct:
`ORAGONO__NETWORK__NAME__QUX=1`,
// private field:
`ORAGONO__SERVER__PASSWORDBYTES="asdf"`,
}
for _, env := range invalidEnvs {
success, _, err := mungeFromEnvironment(&config, env)
if err == nil || success {
t.Errorf("accepted invalid env override `%s`", env)
}
}
}

View File

@ -1,280 +0,0 @@
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package connection_limits
import (
"crypto/md5"
"errors"
"fmt"
"sync"
"time"
"github.com/ergochat/ergo/irc/flatip"
"github.com/ergochat/ergo/irc/utils"
)
var (
ErrLimitExceeded = errors.New("too many concurrent connections")
ErrThrottleExceeded = errors.New("too many recent connection attempts")
)
type CustomLimitConfig struct {
Nets []string
MaxConcurrent int `yaml:"max-concurrent-connections"`
MaxPerWindow int `yaml:"max-connections-per-window"`
}
// tuples the key-value pair of a CIDR and its custom limit/throttle values
type customLimit struct {
name [16]byte
customID string // operator-configured identifier for a custom net
maxConcurrent int
maxPerWindow int
nets []flatip.IPNet
}
type limiterKey struct {
maskedIP flatip.IP
prefixLen uint8 // 0 for the fake nets we generate for custom limits
}
// LimiterConfig controls the automated connection limits.
// rawLimiterConfig contains all the YAML-visible fields;
// LimiterConfig contains additional denormalized private fields
type rawLimiterConfig struct {
Count bool
MaxConcurrent int `yaml:"max-concurrent-connections"`
Throttle bool
Window time.Duration
MaxPerWindow int `yaml:"max-connections-per-window"`
CidrLenIPv4 int `yaml:"cidr-len-ipv4"`
CidrLenIPv6 int `yaml:"cidr-len-ipv6"`
Exempted []string
CustomLimits map[string]CustomLimitConfig `yaml:"custom-limits"`
}
type LimiterConfig struct {
rawLimiterConfig
exemptedNets []flatip.IPNet
customLimits []customLimit
}
func (config *LimiterConfig) UnmarshalYAML(unmarshal func(interface{}) error) (err error) {
if err = unmarshal(&config.rawLimiterConfig); err != nil {
return err
}
return config.postprocess()
}
func (config *LimiterConfig) postprocess() (err error) {
exemptedNets, err := utils.ParseNetList(config.Exempted)
if err != nil {
return fmt.Errorf("Could not parse limiter exemption list: %v", err.Error())
}
config.exemptedNets = make([]flatip.IPNet, len(exemptedNets))
for i, exempted := range exemptedNets {
config.exemptedNets[i] = flatip.FromNetIPNet(exempted)
}
for identifier, customLimitConf := range config.CustomLimits {
nets := make([]flatip.IPNet, len(customLimitConf.Nets))
for i, netStr := range customLimitConf.Nets {
normalizedNet, err := flatip.ParseToNormalizedNet(netStr)
if err != nil {
return fmt.Errorf("Bad net %s in custom-limits block %s: %w", netStr, identifier, err)
}
nets[i] = normalizedNet
}
if len(customLimitConf.Nets) == 0 {
// see #1421: this is the legacy config format where the
// dictionary key of the block is a CIDR string
normalizedNet, err := flatip.ParseToNormalizedNet(identifier)
if err != nil {
return fmt.Errorf("Custom limit block %s has no defined nets", identifier)
}
nets = []flatip.IPNet{normalizedNet}
}
config.customLimits = append(config.customLimits, customLimit{
maxConcurrent: customLimitConf.MaxConcurrent,
maxPerWindow: customLimitConf.MaxPerWindow,
name: md5.Sum([]byte(identifier)),
customID: identifier,
nets: nets,
})
}
return nil
}
// Limiter manages the automated client connection limits.
type Limiter struct {
sync.Mutex
config *LimiterConfig
// IP/CIDR -> count of clients connected from there:
limiter map[limiterKey]int
// IP/CIDR -> throttle state:
throttler map[limiterKey]ThrottleDetails
}
// addrToKey canonicalizes `addr` to a string key, and returns
// the relevant connection limit and throttle max-per-window values
func (cl *Limiter) addrToKey(addr flatip.IP) (key limiterKey, customID string, limit int, throttle int) {
for _, custom := range cl.config.customLimits {
for _, net := range custom.nets {
if net.Contains(addr) {
return limiterKey{maskedIP: custom.name, prefixLen: 0}, custom.customID, custom.maxConcurrent, custom.maxPerWindow
}
}
}
var prefixLen int
if addr.IsIPv4() {
prefixLen = cl.config.CidrLenIPv4
addr = addr.Mask(prefixLen, 32)
prefixLen += 96
} else {
prefixLen = cl.config.CidrLenIPv6
addr = addr.Mask(prefixLen, 128)
}
return limiterKey{maskedIP: addr, prefixLen: uint8(prefixLen)}, "", cl.config.MaxConcurrent, cl.config.MaxPerWindow
}
// AddClient adds a client to our population if possible. If we can't, throws an error instead.
func (cl *Limiter) AddClient(addr flatip.IP) error {
cl.Lock()
defer cl.Unlock()
// we don't track populations for exempted addresses or nets - this is by design
if flatip.IPInNets(addr, cl.config.exemptedNets) {
return nil
}
addrString, _, maxConcurrent, maxPerWindow := cl.addrToKey(addr)
// check limiter
var count int
if cl.config.Count {
count = cl.limiter[addrString] + 1
if count > maxConcurrent {
return ErrLimitExceeded
}
}
if cl.config.Throttle {
details := cl.throttler[addrString] // retrieve mutable throttle state from the map
// add in constant state to process the limiting operation
g := GenericThrottle{
ThrottleDetails: details,
Duration: cl.config.Window,
Limit: maxPerWindow,
}
throttled, _ := g.Touch() // actually check the limit
cl.throttler[addrString] = g.ThrottleDetails // store modified mutable state
if throttled {
// back out the limiter add
return ErrThrottleExceeded
}
}
// success, record in limiter
if cl.config.Count {
cl.limiter[addrString] = count
}
return nil
}
// RemoveClient removes the given address from our population
func (cl *Limiter) RemoveClient(addr flatip.IP) {
cl.Lock()
defer cl.Unlock()
if !cl.config.Count || flatip.IPInNets(addr, cl.config.exemptedNets) {
return
}
addrString, _, _, _ := cl.addrToKey(addr)
count := cl.limiter[addrString]
count -= 1
if count < 0 {
count = 0
}
cl.limiter[addrString] = count
}
type LimiterStatus struct {
Exempt bool
Count int
MaxCount int
Throttle int
MaxPerWindow int
ThrottleDuration time.Duration
}
func (cl *Limiter) Status(addr flatip.IP) (netName string, status LimiterStatus) {
cl.Lock()
defer cl.Unlock()
if flatip.IPInNets(addr, cl.config.exemptedNets) {
status.Exempt = true
return
}
status.ThrottleDuration = cl.config.Window
limiterKey, customID, maxConcurrent, maxPerWindow := cl.addrToKey(addr)
status.MaxCount = maxConcurrent
status.MaxPerWindow = maxPerWindow
status.Count = cl.limiter[limiterKey]
status.Throttle = cl.throttler[limiterKey].Count
netName = customID
if netName == "" {
netName = flatip.IPNet{
IP: limiterKey.maskedIP,
PrefixLen: limiterKey.prefixLen,
}.String()
}
return
}
// ResetThrottle resets the throttle count for an IP
func (cl *Limiter) ResetThrottle(addr flatip.IP) {
cl.Lock()
defer cl.Unlock()
if !cl.config.Throttle || flatip.IPInNets(addr, cl.config.exemptedNets) {
return
}
addrString, _, _, _ := cl.addrToKey(addr)
delete(cl.throttler, addrString)
}
// ApplyConfig atomically applies a config update to a connection limit handler
func (cl *Limiter) ApplyConfig(config *LimiterConfig) {
cl.Lock()
defer cl.Unlock()
if cl.limiter == nil {
cl.limiter = make(map[limiterKey]int)
}
if cl.throttler == nil {
cl.throttler = make(map[limiterKey]ThrottleDetails)
}
cl.config = config
}

View File

@ -1,95 +0,0 @@
// Copyright (c) 2018 Shivaram Lingamneni
// released under the MIT license
package connection_limits
import (
"crypto/md5"
"testing"
"time"
"github.com/ergochat/ergo/irc/flatip"
)
func easyParseIP(ipstr string) (result flatip.IP) {
result, err := flatip.ParseIP(ipstr)
if err != nil {
panic(err)
}
return
}
var baseConfig = LimiterConfig{
rawLimiterConfig: rawLimiterConfig{
Count: true,
MaxConcurrent: 4,
Throttle: true,
Window: time.Second * 600,
MaxPerWindow: 8,
CidrLenIPv4: 32,
CidrLenIPv6: 64,
Exempted: []string{"localhost"},
CustomLimits: map[string]CustomLimitConfig{
"google": {
Nets: []string{"8.8.0.0/16"},
MaxConcurrent: 128,
MaxPerWindow: 256,
},
},
},
}
func TestKeying(t *testing.T) {
config := baseConfig
config.postprocess()
var limiter Limiter
limiter.ApplyConfig(&config)
// an ipv4 /32 looks like a /128 to us after applying the 4-in-6 mapping
key, _, maxConc, maxWin := limiter.addrToKey(easyParseIP("1.1.1.1"))
assertEqual(key.prefixLen, uint8(128), t)
assertEqual(key.maskedIP[12:], []byte{1, 1, 1, 1}, t)
assertEqual(maxConc, 4, t)
assertEqual(maxWin, 8, t)
testIPv6 := easyParseIP("2607:5301:201:3100::7426")
key, _, maxConc, maxWin = limiter.addrToKey(testIPv6)
assertEqual(key.prefixLen, uint8(64), t)
assertEqual(flatip.IP(key.maskedIP), easyParseIP("2607:5301:201:3100::"), t)
assertEqual(maxConc, 4, t)
assertEqual(maxWin, 8, t)
key, _, maxConc, maxWin = limiter.addrToKey(easyParseIP("8.8.4.4"))
assertEqual(key.prefixLen, uint8(0), t)
assertEqual([16]byte(key.maskedIP), md5.Sum([]byte("google")), t)
assertEqual(maxConc, 128, t)
assertEqual(maxWin, 256, t)
}
func TestLimits(t *testing.T) {
regularIP := easyParseIP("2607:5301:201:3100::7426")
config := baseConfig
config.postprocess()
var limiter Limiter
limiter.ApplyConfig(&config)
for i := 0; i < 4; i++ {
err := limiter.AddClient(regularIP)
if err != nil {
t.Errorf("ip should not be blocked, but %v", err)
}
}
err := limiter.AddClient(regularIP)
if err != ErrLimitExceeded {
t.Errorf("ip should be blocked, but %v", err)
}
limiter.RemoveClient(regularIP)
err = limiter.AddClient(regularIP)
if err != nil {
t.Errorf("ip should not be blocked, but %v", err)
}
}

View File

@ -1,51 +0,0 @@
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package connection_limits
import (
"time"
)
// ThrottleDetails holds the connection-throttling details for a subnet/IP.
type ThrottleDetails struct {
Start time.Time
Count int
}
// GenericThrottle allows enforcing limits of the form
// "at most X events per time window of duration Y"
type GenericThrottle struct {
ThrottleDetails // variable state: what events have been seen
// these are constant after creation:
Duration time.Duration // window length to consider
Limit int // number of events allowed per window
}
// Touch checks whether an additional event is allowed:
// it either denies it (by returning false) or allows it (by returning true)
// and records it
func (g *GenericThrottle) Touch() (throttled bool, remainingTime time.Duration) {
return g.touch(time.Now().UTC())
}
func (g *GenericThrottle) touch(now time.Time) (throttled bool, remainingTime time.Duration) {
if g.Limit == 0 {
return // limit of 0 disables throttling
}
elapsed := now.Sub(g.Start)
if elapsed > g.Duration {
// reset window, record the operation
g.Start = now
g.Count = 1
return false, 0
} else if g.Count >= g.Limit {
// we are throttled
return true, g.Start.Add(g.Duration).Sub(now)
} else {
// we are not throttled, record the operation
g.Count += 1
return false, 0
}
}

View File

@ -1,123 +0,0 @@
// Copyright (c) 2018 Shivaram Lingamneni
// released under the MIT license
package connection_limits
import (
"reflect"
"testing"
"time"
)
func assertEqual(supplied, expected interface{}, t *testing.T) {
if !reflect.DeepEqual(supplied, expected) {
t.Errorf("expected %v but got %v", expected, supplied)
}
}
func TestGenericThrottle(t *testing.T) {
minute, _ := time.ParseDuration("1m")
second, _ := time.ParseDuration("1s")
zero, _ := time.ParseDuration("0s")
throttler := GenericThrottle{
Duration: minute,
Limit: 2,
}
now := time.Now()
throttled, remaining := throttler.touch(now)
assertEqual(throttled, false, t)
assertEqual(remaining, zero, t)
now = now.Add(second)
throttled, remaining = throttler.touch(now)
assertEqual(throttled, false, t)
assertEqual(remaining, zero, t)
now = now.Add(second)
throttled, remaining = throttler.touch(now)
assertEqual(throttled, true, t)
assertEqual(remaining, 58*second, t)
now = now.Add(minute)
throttled, remaining = throttler.touch(now)
assertEqual(throttled, false, t)
assertEqual(remaining, zero, t)
}
func TestGenericThrottleDisabled(t *testing.T) {
minute, _ := time.ParseDuration("1m")
throttler := GenericThrottle{
Duration: minute,
Limit: 0,
}
for i := 0; i < 1024; i += 1 {
throttled, _ := throttler.Touch()
if throttled {
t.Error("disabled throttler should not throttle")
}
}
}
func makeTestThrottler(v4len, v6len int) *Limiter {
minute, _ := time.ParseDuration("1m")
maxConnections := 3
config := LimiterConfig{
rawLimiterConfig: rawLimiterConfig{
Count: false,
Throttle: true,
CidrLenIPv4: v4len,
CidrLenIPv6: v6len,
MaxPerWindow: maxConnections,
Window: minute,
},
}
config.postprocess()
var limiter Limiter
limiter.ApplyConfig(&config)
return &limiter
}
func TestConnectionThrottle(t *testing.T) {
throttler := makeTestThrottler(32, 64)
addr := easyParseIP("8.8.8.8")
for i := 0; i < 3; i += 1 {
err := throttler.AddClient(addr)
assertEqual(err, nil, t)
}
err := throttler.AddClient(addr)
assertEqual(err, ErrThrottleExceeded, t)
}
func TestConnectionThrottleIPv6(t *testing.T) {
throttler := makeTestThrottler(32, 64)
var err error
err = throttler.AddClient(easyParseIP("2001:0db8::1"))
assertEqual(err, nil, t)
err = throttler.AddClient(easyParseIP("2001:0db8::2"))
assertEqual(err, nil, t)
err = throttler.AddClient(easyParseIP("2001:0db8::3"))
assertEqual(err, nil, t)
err = throttler.AddClient(easyParseIP("2001:0db8::4"))
assertEqual(err, ErrThrottleExceeded, t)
}
func TestConnectionThrottleIPv4(t *testing.T) {
throttler := makeTestThrottler(24, 64)
var err error
err = throttler.AddClient(easyParseIP("192.168.1.101"))
assertEqual(err, nil, t)
err = throttler.AddClient(easyParseIP("192.168.1.102"))
assertEqual(err, nil, t)
err = throttler.AddClient(easyParseIP("192.168.1.103"))
assertEqual(err, nil, t)
err = throttler.AddClient(easyParseIP("192.168.1.104"))
assertEqual(err, ErrThrottleExceeded, t)
}

View File

@ -1,49 +0,0 @@
// Copyright (c) 2019 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package connection_limits
import (
"sync"
"time"
)
// TorLimiter is a combined limiter and throttler for use on connections
// proxied from a Tor hidden service (so we don't have meaningful IPs,
// a notion of CIDR width, etc.)
type TorLimiter struct {
sync.Mutex
numConnections int
maxConnections int
throttle GenericThrottle
}
func (tl *TorLimiter) Configure(maxConnections int, duration time.Duration, maxConnectionsPerDuration int) {
tl.Lock()
defer tl.Unlock()
tl.maxConnections = maxConnections
tl.throttle.Duration = duration
tl.throttle.Limit = maxConnectionsPerDuration
}
func (tl *TorLimiter) AddClient() error {
tl.Lock()
defer tl.Unlock()
if tl.maxConnections != 0 && tl.maxConnections <= tl.numConnections {
return ErrLimitExceeded
}
throttled, _ := tl.throttle.Touch()
if throttled {
return ErrThrottleExceeded
}
tl.numConnections += 1
return nil
}
func (tl *TorLimiter) RemoveClient() {
tl.Lock()
tl.numConnections -= 1
tl.Unlock()
}

View File

@ -1,14 +1,18 @@
// Copyright (c) 2012-2014 Jeremy Latt
// Copyright (c) 2014-2015 Edmund Huber
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
// Copyright (c) 2016- Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package irc
import "fmt"
const (
// maxLastArgLength is used to simply cap off the final argument when creating general messages where we need to select a limit.
// for instance, in MONITOR lists, RPL_ISUPPORT lists, etc.
maxLastArgLength = 400
// maxTargets is the maximum number of targets for PRIVMSG and NOTICE.
maxTargets = 4
// SemVer is the semantic version of Oragono.
SemVer = "0.3.0"
)
var (
// Ver is the full version of Oragono, used in responses to clients.
Ver = fmt.Sprintf("oragono-%s", SemVer)
)

View File

@ -1,199 +0,0 @@
// Copyright 2010 The Go Authors. All rights reserved.
package custime
import (
"errors"
"time"
)
// see https://github.com/golang/go/blob/7ad512e7ffe576c4894ea84b02e954846fbda643/src/time/format.go#L1251
// This is a forked version of the ParseDuration function that also handles days/months/years
var errLeadingInt = errors.New("time: bad [0-9]*") // never printed
// leadingInt consumes the leading [0-9]* from s.
func leadingInt(s string) (x int64, rem string, err error) {
i := 0
for ; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}
if x > (1<<63-1)/10 {
// overflow
return 0, "", errLeadingInt
}
x = x*10 + int64(c) - '0'
if x < 0 {
// overflow
return 0, "", errLeadingInt
}
}
return x, s[i:], nil
}
// leadingFraction consumes the leading [0-9]* from s.
// It is used only for fractions, so does not return an error on overflow,
// it just stops accumulating precision.
func leadingFraction(s string) (x int64, scale float64, rem string) {
i := 0
scale = 1
overflow := false
for ; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}
if overflow {
continue
}
if x > (1<<63-1)/10 {
// It's possible for overflow to give a positive number, so take care.
overflow = true
continue
}
y := x*10 + int64(c) - '0'
if y < 0 {
overflow = true
continue
}
x = y
scale *= 10
}
return x, scale, s[i:]
}
var unitMap = map[string]int64{
"ns": int64(time.Nanosecond),
"us": int64(time.Microsecond),
"µs": int64(time.Microsecond), // U+00B5 = micro symbol
"μs": int64(time.Microsecond), // U+03BC = Greek letter mu
"ms": int64(time.Millisecond),
"s": int64(time.Second),
"m": int64(time.Minute),
"h": int64(time.Hour),
"d": int64(time.Hour * 24),
"w": int64(time.Hour * 24 * 7),
"mo": int64(time.Hour * 24 * 30),
"y": int64(time.Hour * 24 * 365),
}
// ParseDuration parses a duration string.
// A duration string is a possibly signed sequence of
// decimal numbers, each with optional fraction and a unit suffix,
// such as "300ms", "-1.5h" or "2h45m".
// Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
func ParseDuration(s string) (time.Duration, error) {
// [-+]?([0-9]*(\.[0-9]*)?[a-z]+)+
orig := s
var d int64
neg := false
// Consume [-+]?
if s != "" {
c := s[0]
if c == '-' || c == '+' {
neg = c == '-'
s = s[1:]
}
}
// Special case: if all that is left is "0", this is zero.
if s == "0" {
return 0, nil
}
if s == "" {
return 0, errors.New("time: invalid duration " + orig)
}
for s != "" {
var (
v, f int64 // integers before, after decimal point
scale float64 = 1 // value = v + f/scale
)
var err error
// The next character must be [0-9.]
if !(s[0] == '.' || '0' <= s[0] && s[0] <= '9') {
return 0, errors.New("time: invalid duration " + orig)
}
// Consume [0-9]*
pl := len(s)
v, s, err = leadingInt(s)
if err != nil {
return 0, errors.New("time: invalid duration " + orig)
}
pre := pl != len(s) // whether we consumed anything before a period
// Consume (\.[0-9]*)?
post := false
if s != "" && s[0] == '.' {
s = s[1:]
pl := len(s)
f, scale, s = leadingFraction(s)
post = pl != len(s)
}
if !pre && !post {
// no digits (e.g. ".s" or "-.s")
return 0, errors.New("time: invalid duration " + orig)
}
// Consume unit.
i := 0
for ; i < len(s); i++ {
c := s[i]
if c == '.' || '0' <= c && c <= '9' {
break
}
}
if i == 0 {
return 0, errors.New("time: missing unit in duration " + orig)
}
u := s[:i]
s = s[i:]
unit, ok := unitMap[u]
if !ok {
return 0, errors.New("time: unknown unit " + u + " in duration " + orig)
}
if v > (1<<63-1)/unit {
// overflow
return 0, errors.New("time: invalid duration " + orig)
}
v *= unit
if f > 0 {
// float64 is needed to be nanosecond accurate for fractions of hours.
// v >= 0 && (f*unit/scale) <= 3.6e+12 (ns/h, h is the largest unit)
v += int64(float64(f) * (float64(unit) / scale))
if v < 0 {
// overflow
return 0, errors.New("time: invalid duration " + orig)
}
}
d += v
if d < 0 {
// overflow
return 0, errors.New("time: invalid duration " + orig)
}
}
if neg {
d = -d
}
return time.Duration(d), nil
}
type Duration time.Duration
func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error {
var orig string
var err error
if err = unmarshal(&orig); err != nil {
return err
}
result, err := ParseDuration(orig)
if err == nil {
*d = Duration(result)
}
return err
}

File diff suppressed because it is too large Load Diff

View File

@ -1,45 +0,0 @@
// 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
}

75
irc/debug.go Normal file
View File

@ -0,0 +1,75 @@
// Copyright (c) 2012-2014 Jeremy Latt
// released under the MIT license
package irc
import (
"fmt"
"os"
"runtime"
"runtime/debug"
"runtime/pprof"
"time"
"github.com/DanielOaks/girc-go/ircmsg"
)
// DEBUG GCSTATS/NUMGOROUTINE/etc
func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool {
if !client.flags[Operator] {
return false
}
switch msg.Params[0] {
case "GCSTATS":
stats := debug.GCStats{
Pause: make([]time.Duration, 10),
PauseQuantiles: make([]time.Duration, 5),
}
debug.ReadGCStats(&stats)
client.Notice(fmt.Sprintf("last GC: %s", stats.LastGC.Format(time.RFC1123)))
client.Notice(fmt.Sprintf("num GC: %d", stats.NumGC))
client.Notice(fmt.Sprintf("pause total: %s", stats.PauseTotal))
client.Notice(fmt.Sprintf("pause quantiles min%%: %s", stats.PauseQuantiles[0]))
client.Notice(fmt.Sprintf("pause quantiles 25%%: %s", stats.PauseQuantiles[1]))
client.Notice(fmt.Sprintf("pause quantiles 50%%: %s", stats.PauseQuantiles[2]))
client.Notice(fmt.Sprintf("pause quantiles 75%%: %s", stats.PauseQuantiles[3]))
client.Notice(fmt.Sprintf("pause quantiles max%%: %s", stats.PauseQuantiles[4]))
case "NUMGOROUTINE":
count := runtime.NumGoroutine()
client.Notice(fmt.Sprintf("num goroutines: %d", count))
case "PROFILEHEAP":
profFile := "ergonomadic.mprof"
file, err := os.Create(profFile)
if err != nil {
client.Notice(fmt.Sprintf("error: %s", err))
break
}
defer file.Close()
pprof.Lookup("heap").WriteTo(file, 0)
client.Notice(fmt.Sprintf("written to %s", profFile))
case "STARTCPUPROFILE":
profFile := "ergonomadic.prof"
file, err := os.Create(profFile)
if err != nil {
client.Notice(fmt.Sprintf("error: %s", err))
break
}
if err := pprof.StartCPUProfile(file); err != nil {
defer file.Close()
client.Notice(fmt.Sprintf("error: %s", err))
break
}
client.Notice(fmt.Sprintf("CPU profile writing to %s", profFile))
case "STOPCPUPROFILE":
pprof.StopCPUProfile()
client.Notice(fmt.Sprintf("CPU profiling stopped"))
}
return false
}

View File

@ -1,280 +0,0 @@
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package irc
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/ergochat/ergo/irc/flatip"
"github.com/tidwall/buntdb"
)
const (
keyDlineEntry = "bans.dlinev2 %s"
)
// IPBanInfo holds info about an IP/net ban.
type IPBanInfo struct {
// RequireSASL indicates a "soft" ban; connections are allowed but they must SASL
RequireSASL bool
// Reason is the ban reason.
Reason string `json:"reason"`
// OperReason is an oper ban reason.
OperReason string `json:"oper_reason"`
// OperName is the oper who set the ban.
OperName string `json:"oper_name"`
// time of ban creation
TimeCreated time.Time
// duration of the ban; 0 means "permanent"
Duration time.Duration
}
func (info IPBanInfo) timeLeft() time.Duration {
return time.Until(info.TimeCreated.Add(info.Duration))
}
func (info IPBanInfo) TimeLeft() string {
if info.Duration == 0 {
return "indefinite"
} else {
return info.timeLeft().Truncate(time.Second).String()
}
}
// BanMessage returns the ban message.
func (info IPBanInfo) BanMessage(message string) string {
reason := info.Reason
if reason == "" {
reason = "No reason given"
}
message = fmt.Sprintf(message, reason)
if info.Duration != 0 {
message += fmt.Sprintf(" [%s]", info.TimeLeft())
}
return message
}
// DLineManager manages and dlines.
type DLineManager struct {
sync.RWMutex // tier 1
persistenceMutex sync.Mutex // tier 2
// networks that are dlined:
networks map[flatip.IPNet]IPBanInfo
// this keeps track of expiration timers for temporary bans
expirationTimers map[flatip.IPNet]*time.Timer
server *Server
}
// NewDLineManager returns a new DLineManager.
func NewDLineManager(server *Server) *DLineManager {
var dm DLineManager
dm.networks = make(map[flatip.IPNet]IPBanInfo)
dm.expirationTimers = make(map[flatip.IPNet]*time.Timer)
dm.server = server
dm.loadFromDatastore()
return &dm
}
// AllBans returns all bans (for use with APIs, etc).
func (dm *DLineManager) AllBans() map[string]IPBanInfo {
allb := make(map[string]IPBanInfo)
dm.RLock()
defer dm.RUnlock()
for key, info := range dm.networks {
allb[key.HumanReadableString()] = info
}
return allb
}
// AddNetwork adds a network to the blocked list.
func (dm *DLineManager) AddNetwork(network flatip.IPNet, duration time.Duration, requireSASL bool, reason, operReason, operName string) error {
dm.persistenceMutex.Lock()
defer dm.persistenceMutex.Unlock()
// assemble ban info
info := IPBanInfo{
RequireSASL: requireSASL,
Reason: reason,
OperReason: operReason,
OperName: operName,
TimeCreated: time.Now().UTC(),
Duration: duration,
}
id := dm.addNetworkInternal(network, info)
return dm.persistDline(id, info)
}
func (dm *DLineManager) addNetworkInternal(flatnet flatip.IPNet, info IPBanInfo) (id flatip.IPNet) {
id = flatnet
var timeLeft time.Duration
if info.Duration != 0 {
timeLeft = info.timeLeft()
if timeLeft <= 0 {
return
}
}
dm.Lock()
defer dm.Unlock()
dm.networks[flatnet] = info
dm.cancelTimer(flatnet)
if info.Duration == 0 {
return
}
// set up new expiration timer
timeCreated := info.TimeCreated
processExpiration := func() {
dm.Lock()
defer dm.Unlock()
banInfo, ok := dm.networks[flatnet]
if ok && banInfo.TimeCreated.Equal(timeCreated) {
delete(dm.networks, flatnet)
// TODO(slingamn) here's where we'd remove it from the radix tree
delete(dm.expirationTimers, flatnet)
}
}
dm.expirationTimers[flatnet] = time.AfterFunc(timeLeft, processExpiration)
return
}
func (dm *DLineManager) cancelTimer(flatnet flatip.IPNet) {
oldTimer := dm.expirationTimers[flatnet]
if oldTimer != nil {
oldTimer.Stop()
delete(dm.expirationTimers, flatnet)
}
}
func (dm *DLineManager) persistDline(id flatip.IPNet, info IPBanInfo) error {
// save in datastore
dlineKey := fmt.Sprintf(keyDlineEntry, id.String())
// assemble json from ban info
b, err := json.Marshal(info)
if err != nil {
dm.server.logger.Error("internal", "couldn't marshal d-line", err.Error())
return err
}
bstr := string(b)
var setOptions *buntdb.SetOptions
if info.Duration != 0 {
setOptions = &buntdb.SetOptions{Expires: true, TTL: info.Duration}
}
err = dm.server.store.Update(func(tx *buntdb.Tx) error {
_, _, err := tx.Set(dlineKey, bstr, setOptions)
return err
})
if err != nil {
dm.server.logger.Error("internal", "couldn't store d-line", err.Error())
}
return err
}
func (dm *DLineManager) unpersistDline(id flatip.IPNet) error {
dlineKey := fmt.Sprintf(keyDlineEntry, id.String())
return dm.server.store.Update(func(tx *buntdb.Tx) error {
_, err := tx.Delete(dlineKey)
return err
})
}
// RemoveNetwork removes a network from the blocked list.
func (dm *DLineManager) RemoveNetwork(network flatip.IPNet) error {
dm.persistenceMutex.Lock()
defer dm.persistenceMutex.Unlock()
id := network
present := func() bool {
dm.Lock()
defer dm.Unlock()
_, ok := dm.networks[id]
delete(dm.networks, id)
dm.cancelTimer(id)
return ok
}()
if !present {
return errNoExistingBan
}
return dm.unpersistDline(id)
}
// CheckIP returns whether or not an IP address was banned, and how long it is banned for.
func (dm *DLineManager) CheckIP(addr flatip.IP) (isBanned bool, info IPBanInfo) {
dm.RLock()
defer dm.RUnlock()
// check networks
// TODO(slingamn) use a radix tree as the data plane for this
for flatnet, info := range dm.networks {
if flatnet.Contains(addr) {
return true, info
}
}
// no matches!
return
}
func (dm *DLineManager) loadFromDatastore() {
dlinePrefix := fmt.Sprintf(keyDlineEntry, "")
dm.server.store.View(func(tx *buntdb.Tx) error {
tx.AscendGreaterOrEqual("", dlinePrefix, func(key, value string) bool {
if !strings.HasPrefix(key, dlinePrefix) {
return false
}
// get address name
key = strings.TrimPrefix(key, dlinePrefix)
// load addr/net
hostNet, err := flatip.ParseToNormalizedNet(key)
if err != nil {
dm.server.logger.Error("internal", "bad dline cidr", err.Error())
return true
}
// load ban info
var info IPBanInfo
err = json.Unmarshal([]byte(value), &info)
if err != nil {
dm.server.logger.Error("internal", "bad dline data", err.Error())
return true
}
// set opername if it isn't already set
if info.OperName == "" {
info.OperName = dm.server.name
}
// add to the server
dm.addNetworkInternal(hostNet, info)
return true
})
return nil
})
}
func (s *Server) loadDLines() {
s.dlines = NewDLineManager(s)
}

View File

@ -1,102 +0,0 @@
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package email
import (
"bytes"
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
dkim "github.com/emersion/go-msgauth/dkim"
)
var (
ErrMissingFields = errors.New("DKIM config is missing fields")
)
type DKIMConfig struct {
Domain string
Selector string
KeyFile string `yaml:"key-file"`
privKey crypto.Signer
}
func (dkim *DKIMConfig) Enabled() bool {
return dkim.Domain != ""
}
func (dkim *DKIMConfig) Postprocess() (err error) {
if !dkim.Enabled() {
return nil
}
if dkim.Selector == "" || dkim.KeyFile == "" {
return ErrMissingFields
}
keyBytes, err := os.ReadFile(dkim.KeyFile)
if err != nil {
return fmt.Errorf("Could not read DKIM key file: %w", err)
}
dkim.privKey, err = parseDKIMPrivKey(keyBytes)
if err != nil {
return fmt.Errorf("Could not parse DKIM key file: %w", err)
}
return nil
}
func parseDKIMPrivKey(input []byte) (crypto.Signer, error) {
if len(input) == 0 {
return nil, errors.New("DKIM private key is empty")
}
// raw ed25519 private key format
if len(input) == ed25519.PrivateKeySize {
return ed25519.PrivateKey(input), nil
}
d, _ := pem.Decode(input)
if d == nil {
return nil, errors.New("Invalid PEM data for DKIM private key")
}
if rsaKey, err := x509.ParsePKCS1PrivateKey(d.Bytes); err == nil {
return rsaKey, nil
}
if k, err := x509.ParsePKCS8PrivateKey(d.Bytes); err == nil {
switch key := k.(type) {
case *rsa.PrivateKey:
return key, nil
case ed25519.PrivateKey:
return key, nil
default:
return nil, fmt.Errorf("Unacceptable type for DKIM private key: %T", k)
}
}
return nil, errors.New("No acceptable format for DKIM private key")
}
func DKIMSign(message []byte, dkimConfig DKIMConfig) (result []byte, err error) {
options := dkim.SignOptions{
Domain: dkimConfig.Domain,
Selector: dkimConfig.Selector,
Signer: dkimConfig.privKey,
HeaderCanonicalization: dkim.CanonicalizationRelaxed,
BodyCanonicalization: dkim.CanonicalizationRelaxed,
}
input := bytes.NewBuffer(message)
output := bytes.NewBuffer(make([]byte, 0, len(message)+1024))
err = dkim.Sign(output, input, &options)
return output.Bytes(), err
}

View File

@ -1,268 +0,0 @@
// Copyright (c) 2020 Shivaram Lingamneni
// released under the MIT license
package email
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"net"
"os"
"regexp"
"strings"
"time"
"github.com/ergochat/ergo/irc/custime"
"github.com/ergochat/ergo/irc/smtp"
"github.com/ergochat/ergo/irc/utils"
)
var (
ErrBlacklistedAddress = errors.New("Email address is blacklisted")
ErrInvalidAddress = errors.New("Email address is invalid")
ErrNoMXRecord = errors.New("Couldn't resolve MX record")
)
type BlacklistSyntax uint
const (
BlacklistSyntaxGlob BlacklistSyntax = iota
BlacklistSyntaxRegexp
)
func blacklistSyntaxFromString(status string) (BlacklistSyntax, error) {
switch strings.ToLower(status) {
case "glob", "":
return BlacklistSyntaxGlob, nil
case "re", "regex", "regexp":
return BlacklistSyntaxRegexp, nil
default:
return BlacklistSyntaxRegexp, fmt.Errorf("Unknown blacklist syntax type `%s`", status)
}
}
func (bs *BlacklistSyntax) UnmarshalYAML(unmarshal func(interface{}) error) error {
var orig string
var err error
if err = unmarshal(&orig); err != nil {
return err
}
if result, err := blacklistSyntaxFromString(orig); err == nil {
*bs = result
return nil
} else {
return err
}
}
type MTAConfig struct {
Server string
Port int
Username string
Password string
ImplicitTLS bool `yaml:"implicit-tls"`
}
type MailtoConfig struct {
// legacy config format assumed the use of an MTA/smarthost,
// so server, port, etc. appear directly at top level
// XXX: see https://github.com/go-yaml/yaml/issues/63
MTAConfig `yaml:",inline"`
Enabled bool
Sender string
HeloDomain string `yaml:"helo-domain"`
RequireTLS bool `yaml:"require-tls"`
Protocol string `yaml:"protocol"`
LocalAddress string `yaml:"local-address"`
localAddress net.Addr
VerifyMessageSubject string `yaml:"verify-message-subject"`
DKIM DKIMConfig
MTAReal MTAConfig `yaml:"mta"`
AddressBlacklist []string `yaml:"address-blacklist"`
AddressBlacklistSyntax BlacklistSyntax `yaml:"address-blacklist-syntax"`
AddressBlacklistFile string `yaml:"address-blacklist-file"`
blacklistRegexes []*regexp.Regexp
Timeout time.Duration
PasswordReset struct {
Enabled bool
Cooldown custime.Duration
Timeout custime.Duration
} `yaml:"password-reset"`
}
func (config *MailtoConfig) compileBlacklistEntry(source string) (re *regexp.Regexp, err error) {
if config.AddressBlacklistSyntax == BlacklistSyntaxGlob {
return utils.CompileGlob(source, false)
} else {
return regexp.Compile(fmt.Sprintf("^%s$", source))
}
}
func (config *MailtoConfig) processBlacklistFile(filename string) (result []*regexp.Regexp, err error) {
f, err := os.Open(filename)
if err != nil {
return
}
defer f.Close()
reader := bufio.NewReader(f)
lineNo := 0
for {
line, err := reader.ReadString('\n')
lineNo++
line = strings.TrimSpace(line)
if line != "" && line[0] != '#' {
if compiled, compileErr := config.compileBlacklistEntry(line); compileErr == nil {
result = append(result, compiled)
} else {
return result, fmt.Errorf("Failed to compile line %d of blacklist-regex-file `%s`: %w", lineNo, line, compileErr)
}
}
switch err {
case io.EOF:
return result, nil
case nil:
continue
default:
return result, err
}
}
}
func (config *MailtoConfig) Postprocess(heloDomain string) (err error) {
if config.Sender == "" {
return errors.New("Invalid mailto sender address")
}
// check for MTA config fields at top level,
// copy to MTAReal if present
if config.Server != "" && config.MTAReal.Server == "" {
config.MTAReal = config.MTAConfig
}
if config.HeloDomain == "" {
config.HeloDomain = heloDomain
}
if config.AddressBlacklistFile != "" {
config.blacklistRegexes, err = config.processBlacklistFile(config.AddressBlacklistFile)
if err != nil {
return err
}
} 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 != "" {
// smarthost, nothing more to validate
return nil
}
return config.DKIM.Postprocess()
}
// are we sending email directly, as opposed to deferring to an MTA?
func (config *MailtoConfig) DirectSendingEnabled() bool {
return config.MTAReal.Server == ""
}
// get the preferred MX record hostname, "" on error
func lookupMX(domain string) (server string) {
var minPref uint16
results, err := net.LookupMX(domain)
if err != nil {
return
}
for _, result := range results {
if minPref == 0 || result.Pref < minPref {
server, minPref = result.Host, result.Pref
}
}
return
}
func ComposeMail(config MailtoConfig, recipient, subject string) (message bytes.Buffer) {
fmt.Fprintf(&message, "From: %s\r\n", config.Sender)
fmt.Fprintf(&message, "To: %s\r\n", recipient)
dkimDomain := config.DKIM.Domain
if dkimDomain != "" {
fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), dkimDomain)
} else {
// #2108: send Message-ID even if dkim is not enabled
fmt.Fprintf(&message, "Message-ID: <%s-%s>\r\n", utils.GenerateSecretKey(), config.Sender)
}
fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
fmt.Fprintf(&message, "Subject: %s\r\n", subject)
message.WriteString("\r\n") // blank line: end headers, begin message body
return message
}
func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) {
recipientLower := strings.ToLower(recipient)
for _, reg := range config.blacklistRegexes {
if reg.MatchString(recipientLower) {
return ErrBlacklistedAddress
}
}
if config.DKIM.Enabled() {
msg, err = DKIMSign(msg, config.DKIM)
if err != nil {
return
}
}
var addr string
var auth smtp.Auth
var implicitTLS bool
if !config.DirectSendingEnabled() {
addr = fmt.Sprintf("%s:%d", config.MTAReal.Server, config.MTAReal.Port)
if config.MTAReal.Username != "" && config.MTAReal.Password != "" {
auth = smtp.PlainAuth("", config.MTAReal.Username, config.MTAReal.Password, config.MTAReal.Server)
}
implicitTLS = config.MTAReal.ImplicitTLS
} else {
idx := strings.IndexByte(recipient, '@')
if idx == -1 {
return ErrInvalidAddress
}
mx := lookupMX(recipient[idx+1:])
if mx == "" {
return ErrNoMXRecord
}
addr = fmt.Sprintf("%s:smtp", mx)
}
return smtp.SendMail(
addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg,
config.RequireTLS, implicitTLS, config.Protocol, config.localAddress, config.Timeout,
)
}

View File

@ -1,104 +0,0 @@
// Copyright (c) 2012-2014 Jeremy Latt
// Copyright (c) 2014-2015 Edmund Huber
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
// released under the MIT license
package irc
import (
"errors"
"fmt"
"time"
"github.com/ergochat/ergo/irc/utils"
)
// Runtime Errors
var (
errAccountAlreadyRegistered = errors.New(`Account already exists`)
errAccountAlreadyUnregistered = errors.New(`That account name was registered previously and can't be reused`)
errAccountAlreadyVerified = errors.New(`Account is already verified`)
errAccountCantDropPrimaryNick = errors.New("Can't unreserve primary nickname")
errAccountCreation = errors.New("Account could not be created")
errAccountDoesNotExist = errors.New("Account does not exist")
errAccountInvalidCredentials = errors.New("Invalid account credentials")
errAccountBadPassphrase = errors.New(`Passphrase contains forbidden characters or is otherwise invalid`)
errAccountNickReservationFailed = errors.New("Could not (un)reserve nick")
errAccountNotLoggedIn = errors.New("You're not logged into an account")
errAccountAlreadyLoggedIn = errors.New("You're already logged into an account")
errAccountTooManyNicks = errors.New("Account has too many reserved nicks")
errAccountUnverified = errors.New(`Account is not yet verified`)
errAccountSuspended = errors.New(`Account has been suspended`)
errAccountVerificationFailed = errors.New("Account verification failed")
errAccountVerificationInvalidCode = errors.New("Invalid account verification code")
errAccountUpdateFailed = errors.New(`Error while updating your account information`)
errAccountMustHoldNick = errors.New(`You must hold that nickname in order to register it`)
errAuthRequired = errors.New("You must be logged into an account to do this")
errAuthzidAuthcidMismatch = errors.New(`authcid and authzid must be the same`)
errCertfpAlreadyExists = errors.New(`An account already exists for your certificate fingerprint`)
errChannelNotOwnedByAccount = errors.New("Channel not owned by the specified account")
errChannelTransferNotOffered = errors.New(`You weren't offered ownership of that channel`)
errChannelAlreadyRegistered = errors.New("Channel is already registered")
errChannelNotRegistered = errors.New("Channel is not registered")
errChannelNameInUse = errors.New(`Channel name in use`)
errInvalidChannelName = errors.New(`Invalid channel name`)
errMonitorLimitExceeded = errors.New("Monitor limit exceeded")
errNickMissing = errors.New("nick missing")
errNicknameInvalid = errors.New("invalid nickname")
errNicknameInUse = errors.New("nickname in use")
errInsecureReattach = errors.New("insecure reattach")
errNicknameReserved = errors.New("nickname is reserved")
errNickAccountMismatch = errors.New(`Your nickname must match your account name; try logging out and logging back in with SASL`)
errNoExistingBan = errors.New("Ban does not exist")
errNoSuchChannel = errors.New(`No such channel`)
errChannelPurged = errors.New(`This channel was purged by the server operators and cannot be used`)
errChannelPurgedAlready = errors.New(`This channel was already purged and cannot be purged again`)
errConfusableIdentifier = errors.New("This identifier is confusable with one already in use")
errInsufficientPrivs = errors.New("Insufficient privileges")
errInvalidUsername = errors.New("Invalid username")
errFeatureDisabled = errors.New(`That feature is disabled`)
errBanned = errors.New("IP or nickmask banned")
errInvalidParams = utils.ErrInvalidParams
errNoVhost = errors.New(`You do not have an approved vhost`)
errLimitExceeded = errors.New("Limit exceeded")
errNoop = errors.New("Action was a no-op")
errCASFailed = errors.New("Compare-and-swap update of database value failed")
errEmptyCredentials = errors.New("No more credentials are approved")
errCredsExternallyManaged = errors.New("Credentials are externally managed and cannot be changed here")
errNoSCRAMCredentials = errors.New("SCRAM credentials are not initialized for this account; consult the user guide")
errInvalidMultilineBatch = errors.New("Invalid multiline batch")
errTimedOut = errors.New("Operation timed out")
errInvalidUtf8 = errors.New("Message rejected for invalid utf8")
errClientDestroyed = errors.New("Client was already destroyed")
errTooManyChannels = errors.New("You have joined too many channels")
errWrongChannelKey = errors.New("Cannot join password-protected channel without the password")
errInviteOnly = errors.New("Cannot join invite-only channel without an invite")
errRegisteredOnly = errors.New("Cannot join registered-only channel without an account")
errValidEmailRequired = errors.New("A valid email address is required for account registration")
errInvalidAccountRename = errors.New("Account renames can only change the casefolding of the account name")
errNameReserved = errors.New(`Name reserved due to a prior registration`)
errInvalidBearerTokenType = errors.New("invalid bearer token type")
)
// String Errors
var (
errCouldNotStabilize = errors.New("Could not stabilize string while casefolding")
errStringIsEmpty = errors.New("String is empty")
errInvalidCharacter = errors.New("Invalid character")
)
type CertKeyError struct {
Err error
}
func (ck *CertKeyError) Error() string {
return fmt.Sprintf("Invalid TLS cert/key pair: %v", ck.Err)
}
type ThrottleError struct {
time.Duration
}
func (te *ThrottleError) Error() string {
return fmt.Sprintf(`Please wait at least %v and try again`, te.Duration.Round(time.Millisecond))
}

View File

@ -1,123 +0,0 @@
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package irc
import (
"maps"
"time"
)
// fakelag is a system for artificially delaying commands when a user issues
// them too rapidly
type FakelagState uint
const (
// initially, the client is "bursting" and can send n commands without
// encountering fakelag
FakelagBursting FakelagState = iota
// after that, they're "throttled" and we sleep in between commands until
// they're spaced sufficiently far apart
FakelagThrottled
)
// this is intentionally not threadsafe, because it should only be touched
// from the loop that accepts the client's input and runs commands
type Fakelag struct {
config FakelagConfig
suspended bool
nowFunc func() time.Time
sleepFunc func(time.Duration)
state FakelagState
burstCount uint // number of messages sent in the current burst
lastTouch time.Time
}
func (fl *Fakelag) Initialize(config FakelagConfig) {
fl.config = config
// XXX don't share mutable member CommandBudgets:
if config.CommandBudgets != nil {
fl.config.CommandBudgets = maps.Clone(config.CommandBudgets)
}
fl.nowFunc = time.Now
fl.sleepFunc = time.Sleep
fl.state = FakelagBursting
}
// Idempotently turn off fakelag if it's enabled
func (fl *Fakelag) Suspend() {
if fl.config.Enabled {
fl.suspended = true
fl.config.Enabled = false
}
}
// Idempotently turn fakelag back on if it was previously Suspend'ed
func (fl *Fakelag) Unsuspend() {
if fl.suspended {
fl.config.Enabled = true
fl.suspended = false
}
}
// register a new command, sleep if necessary to delay it
func (fl *Fakelag) Touch(command string) {
if !fl.config.Enabled {
return
}
if budget, ok := fl.config.CommandBudgets[command]; ok && budget > 0 {
fl.config.CommandBudgets[command] = budget - 1
return
}
now := fl.nowFunc()
// XXX if lastTouch.IsZero(), treat it as "very far in the past", which is fine
elapsed := now.Sub(fl.lastTouch)
fl.lastTouch = now
if fl.state == FakelagBursting {
// determine if the previous burst is over
if elapsed > fl.config.Cooldown {
fl.burstCount = 0
}
fl.burstCount++
if fl.burstCount > fl.config.BurstLimit {
// reset burst window for next time
fl.burstCount = 0
// transition to throttling
fl.state = FakelagThrottled
// continue to throttling logic
} else {
return
}
}
if fl.state == FakelagThrottled {
if elapsed > fl.config.Cooldown {
// let them burst again
fl.state = FakelagBursting
fl.burstCount = 1
return
}
var sleepDuration time.Duration
if fl.config.MessagesPerWindow > 0 {
// space them out by at least window/messagesperwindow
sleepDuration = time.Duration((int64(fl.config.Window) / int64(fl.config.MessagesPerWindow)) - int64(elapsed))
} else {
// only burst messages are allowed: sleep until cooldown expires,
// then count this as a burst message
sleepDuration = time.Duration(int64(fl.config.Cooldown) - int64(elapsed))
fl.state = FakelagBursting
fl.burstCount = 1
}
if sleepDuration > 0 {
fl.sleepFunc(sleepDuration)
// the touch time should take into account the time we slept
fl.lastTouch = fl.nowFunc()
}
}
}

View File

@ -1,155 +0,0 @@
// Copyright (c) 2018 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// released under the MIT license
package irc
import (
"testing"
"time"
)
type mockTime struct {
now time.Time
sleepList []time.Duration
lastCheckedSleep int
}
func (mt *mockTime) Now() (now time.Time) {
return mt.now
}
func (mt *mockTime) Sleep(dur time.Duration) {
mt.sleepList = append(mt.sleepList, dur)
mt.pause(dur)
}
func (mt *mockTime) pause(dur time.Duration) {
mt.now = mt.now.Add(dur)
}
func (mt *mockTime) lastSleep() (slept bool, duration time.Duration) {
if mt.lastCheckedSleep == len(mt.sleepList)-1 {
slept = false
return
}
slept = true
mt.lastCheckedSleep += 1
duration = mt.sleepList[mt.lastCheckedSleep]
return
}
func newFakelagForTesting(window time.Duration, burstLimit uint, throttleMessagesPerWindow uint, cooldown time.Duration) (*Fakelag, *mockTime) {
fl := Fakelag{}
fl.config = FakelagConfig{
Enabled: true,
Window: window,
BurstLimit: burstLimit,
MessagesPerWindow: throttleMessagesPerWindow,
Cooldown: cooldown,
}
mt := new(mockTime)
mt.now, _ = time.Parse("Mon Jan 2 15:04:05 -0700 MST 2006", "Mon Jan 2 15:04:05 -0700 MST 2006")
mt.lastCheckedSleep = -1
fl.nowFunc = mt.Now
fl.sleepFunc = mt.Sleep
return &fl, mt
}
func TestFakelag(t *testing.T) {
window, _ := time.ParseDuration("1s")
fl, mt := newFakelagForTesting(window, 3, 2, window)
fl.Touch("")
slept, _ := mt.lastSleep()
if slept {
t.Fatalf("should not have slept")
}
interval, _ := time.ParseDuration("100ms")
for i := 0; i < 2; i++ {
mt.pause(interval)
fl.Touch("")
slept, _ := mt.lastSleep()
if slept {
t.Fatalf("should not have slept")
}
}
mt.pause(interval)
fl.Touch("")
if fl.state != FakelagThrottled {
t.Fatalf("should be throttled")
}
slept, duration := mt.lastSleep()
if !slept {
t.Fatalf("should have slept due to fakelag")
}
expected, _ := time.ParseDuration("400ms")
if duration != expected {
t.Fatalf("incorrect sleep time: %v != %v", expected, duration)
}
// send another message without a pause; we should have to sleep for 500 msec
fl.Touch("")
if fl.state != FakelagThrottled {
t.Fatalf("should be throttled")
}
slept, duration = mt.lastSleep()
expected, _ = time.ParseDuration("500ms")
if duration != expected {
t.Fatalf("incorrect sleep time: %v != %v", duration, expected)
}
mt.pause(interval * 6)
fl.Touch("")
if fl.state != FakelagThrottled {
t.Fatalf("should still be throttled")
}
slept, duration = mt.lastSleep()
if duration != 0 {
t.Fatalf("we paused for long enough that we shouldn't sleep here")
}
mt.pause(window * 2)
fl.Touch("")
if fl.state != FakelagBursting {
t.Fatalf("should be bursting again")
}
slept, _ = mt.lastSleep()
if slept {
t.Fatalf("should not have slept")
}
}
func TestSuspend(t *testing.T) {
window, _ := time.ParseDuration("1s")
fl, _ := newFakelagForTesting(window, 3, 2, window)
assertEqual(fl.config.Enabled, true)
// suspend idempotently disables
fl.Suspend()
assertEqual(fl.config.Enabled, false)
fl.Suspend()
assertEqual(fl.config.Enabled, false)
// unsuspend idempotently enables
fl.Unsuspend()
assertEqual(fl.config.Enabled, true)
fl.Unsuspend()
assertEqual(fl.config.Enabled, true)
fl.Suspend()
assertEqual(fl.config.Enabled, false)
fl2, _ := newFakelagForTesting(window, 3, 2, window)
fl2.config.Enabled = false
// if we were never enabled, suspend and unsuspend are both no-ops
fl2.Suspend()
assertEqual(fl2.config.Enabled, false)
fl2.Suspend()
assertEqual(fl2.config.Enabled, false)
fl2.Unsuspend()
assertEqual(fl2.config.Enabled, false)
fl2.Unsuspend()
assertEqual(fl2.config.Enabled, false)
}

View File

@ -1,33 +0,0 @@
// Copyright 2020 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// Released under the MIT license
package flatip
// begin ad-hoc utilities
// ParseToNormalizedNet attempts to interpret a string either as an IP
// network in CIDR notation, returning an IPNet, or as an IP address,
// returning an IPNet that contains only that address.
func ParseToNormalizedNet(netstr string) (ipnet IPNet, err error) {
_, ipnet, err = ParseCIDR(netstr)
if err == nil {
return
}
ip, err := ParseIP(netstr)
if err == nil {
ipnet.IP = ip
ipnet.PrefixLen = 128
}
return
}
// IPInNets is a convenience function for testing whether an IP is contained
// in any member of a slice of IPNet's.
func IPInNets(addr IP, nets []IPNet) bool {
for _, net := range nets {
if net.Contains(addr) {
return true
}
}
return false
}

View File

@ -1,220 +0,0 @@
// Copyright 2020 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// Copyright 2009 The Go Authors
// Released under the MIT license
package flatip
import (
"bytes"
"errors"
"net"
)
var (
v4InV6Prefix = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}
IPv6loopback = IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
IPv6zero = IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
IPv4zero = IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0, 0, 0}
ErrInvalidIPString = errors.New("String could not be interpreted as an IP address")
)
// packed versions of net.IP and net.IPNet; these are pure value types,
// so they can be compared with == and used as map keys.
// IP is a 128-bit representation of an IP address, using the 4-in-6 mapping
// to represent IPv4 addresses.
type IP [16]byte
// IPNet is a IP network. In a valid value, all bits after PrefixLen are zeroes.
type IPNet struct {
IP
PrefixLen uint8
}
// NetIP converts an IP into a net.IP.
func (ip IP) NetIP() (result net.IP) {
result = make(net.IP, 16)
copy(result[:], ip[:])
return
}
// FromNetIP converts a net.IP into an IP.
func FromNetIP(ip net.IP) (result IP) {
if len(ip) == 16 {
copy(result[:], ip[:])
} else {
result[10] = 0xff
result[11] = 0xff
copy(result[12:], ip[:])
}
return
}
// IPv4 returns the IP address representation of a.b.c.d
func IPv4(a, b, c, d byte) (result IP) {
copy(result[:12], v4InV6Prefix)
result[12] = a
result[13] = b
result[14] = c
result[15] = d
return
}
// ParseIP parses a string representation of an IP address into an IP.
// Unlike net.ParseIP, it returns an error instead of a zero value on failure,
// since the zero value of `IP` is a representation of a valid IP (::0, the
// IPv6 "unspecified address").
func ParseIP(ipstr string) (ip IP, err error) {
// TODO reimplement this without net.ParseIP
netip := net.ParseIP(ipstr)
if netip == nil {
err = ErrInvalidIPString
return
}
netip = netip.To16()
copy(ip[:], netip)
return
}
// String returns the string representation of an IP
func (ip IP) String() string {
// TODO reimplement this without using (net.IP).String()
return (net.IP)(ip[:]).String()
}
// IsIPv4 returns whether the IP is an IPv4 address.
func (ip IP) IsIPv4() bool {
return bytes.Equal(ip[:12], v4InV6Prefix)
}
// IsLoopback returns whether the IP is a loopback address.
func (ip IP) IsLoopback() bool {
if ip.IsIPv4() {
return ip[12] == 127
} else {
return ip == IPv6loopback
}
}
func (ip IP) IsUnspecified() bool {
return ip == IPv4zero || ip == IPv6zero
}
func rawCidrMask(length int) (m IP) {
n := uint(length)
for i := 0; i < 16; i++ {
if n >= 8 {
m[i] = 0xff
n -= 8
continue
}
m[i] = ^byte(0xff >> n)
return
}
return
}
func (ip IP) applyMask(mask IP) (result IP) {
for i := 0; i < 16; i += 1 {
result[i] = ip[i] & mask[i]
}
return
}
func cidrMask(ones, bits int) (result IP) {
switch bits {
case 32:
return rawCidrMask(96 + ones)
case 128:
return rawCidrMask(ones)
default:
return
}
}
// Mask returns the result of masking ip with the CIDR mask of
// length 'ones', out of a total of 'bits' (which must be either
// 32 for an IPv4 subnet or 128 for an IPv6 subnet).
func (ip IP) Mask(ones, bits int) (result IP) {
return ip.applyMask(cidrMask(ones, bits))
}
// ToNetIPNet converts an IPNet into a net.IPNet.
func (cidr IPNet) ToNetIPNet() (result net.IPNet) {
return net.IPNet{
IP: cidr.IP.NetIP(),
Mask: net.CIDRMask(int(cidr.PrefixLen), 128),
}
}
// Contains retuns whether the network contains `ip`.
func (cidr IPNet) Contains(ip IP) bool {
maskedIP := ip.Mask(int(cidr.PrefixLen), 128)
return cidr.IP == maskedIP
}
func (cidr IPNet) Size() (ones, bits int) {
if cidr.IP.IsIPv4() {
return int(cidr.PrefixLen) - 96, 32
} else {
return int(cidr.PrefixLen), 128
}
}
// FromNetIPnet converts a net.IPNet into an IPNet.
func FromNetIPNet(network net.IPNet) (result IPNet) {
ones, _ := network.Mask.Size()
if len(network.IP) == 16 {
copy(result.IP[:], network.IP[:])
} else {
result.IP[10] = 0xff
result.IP[11] = 0xff
copy(result.IP[12:], network.IP[:])
ones += 96
}
// perform masking so that equal CIDRs are ==
result.IP = result.IP.Mask(ones, 128)
result.PrefixLen = uint8(ones)
return
}
// String returns a string representation of an IPNet.
func (cidr IPNet) String() string {
ip := make(net.IP, 16)
copy(ip[:], cidr.IP[:])
ipnet := net.IPNet{
IP: ip,
Mask: net.CIDRMask(int(cidr.PrefixLen), 128),
}
return ipnet.String()
}
// HumanReadableString returns a string representation of an IPNet;
// if the network contains only a single IP address, it returns
// a representation of that address.
func (cidr IPNet) HumanReadableString() string {
if cidr.PrefixLen == 128 {
return cidr.IP.String()
}
return cidr.String()
}
// IsZero tests whether ipnet is the zero value of an IPNet, 0::0/0.
// Although this is a valid subnet, it can still be used as a sentinel
// value in some contexts.
func (ipnet IPNet) IsZero() bool {
return ipnet == IPNet{}
}
// ParseCIDR parses a string representation of an IP network in CIDR notation,
// then returns it as an IPNet (along with the original, unmasked address).
func ParseCIDR(netstr string) (ip IP, ipnet IPNet, err error) {
// TODO reimplement this without net.ParseCIDR
nip, nipnet, err := net.ParseCIDR(netstr)
if err != nil {
return
}
return FromNetIP(nip), FromNetIPNet(*nipnet), nil
}

View File

@ -1,208 +0,0 @@
package flatip
import (
"bytes"
"fmt"
"math/rand"
"net"
"reflect"
"testing"
"time"
)
func easyParseIP(ipstr string) (result net.IP) {
result = net.ParseIP(ipstr)
if result == nil {
panic(ipstr)
}
return
}
func easyParseFlat(ipstr string) (result IP) {
x := easyParseIP(ipstr)
return FromNetIP(x)
}
func easyParseIPNet(nipstr string) (result net.IPNet) {
_, nip, err := net.ParseCIDR(nipstr)
if err != nil {
panic(err)
}
return *nip
}
func TestBasic(t *testing.T) {
nip := easyParseIP("8.8.8.8")
flatip := FromNetIP(nip)
if flatip.String() != "8.8.8.8" {
t.Errorf("conversions don't work")
}
}
func TestLoopback(t *testing.T) {
localhost_v4 := easyParseFlat("127.0.0.1")
localhost_v4_again := easyParseFlat("127.2.3.4")
google := easyParseFlat("8.8.8.8")
loopback_v6 := easyParseFlat("::1")
google_v6 := easyParseFlat("2607:f8b0:4006:801::2004")
if !(localhost_v4.IsLoopback() && localhost_v4_again.IsLoopback() && loopback_v6.IsLoopback()) {
t.Errorf("can't detect loopbacks")
}
if google_v6.IsLoopback() || google.IsLoopback() {
t.Errorf("incorrectly detected loopbacks")
}
}
func TestContains(t *testing.T) {
nipnet := easyParseIPNet("8.8.0.0/16")
flatipnet := FromNetIPNet(nipnet)
nip := easyParseIP("8.8.8.8")
flatip_ := FromNetIP(nip)
if !flatipnet.Contains(flatip_) {
t.Errorf("contains doesn't work")
}
}
var testIPStrs = []string{
"8.8.8.8",
"127.0.0.1",
"1.1.1.1",
"128.127.65.64",
"2001:0db8::1",
"::1",
"255.255.255.255",
}
func doMaskingTest(ip net.IP, t *testing.T) {
flat := FromNetIP(ip)
netLen := len(ip) * 8
for i := 0; i < netLen; i++ {
masked := flat.Mask(i, netLen)
netMask := net.CIDRMask(i, netLen)
netMasked := ip.Mask(netMask)
if !bytes.Equal(masked[:], netMasked.To16()) {
t.Errorf("Masking %s with %d/%d; expected %s, got %s", ip.String(), i, netLen, netMasked.String(), masked.String())
}
}
}
func assertEqual(found, expected interface{}) {
if !reflect.DeepEqual(found, expected) {
panic(fmt.Sprintf("expected %#v, found %#v", expected, found))
}
}
func TestSize(t *testing.T) {
_, net, err := ParseCIDR("8.8.8.8/24")
if err != nil {
panic(err)
}
ones, bits := net.Size()
assertEqual(ones, 24)
assertEqual(bits, 32)
_, net, err = ParseCIDR("2001::0db8/64")
if err != nil {
panic(err)
}
ones, bits = net.Size()
assertEqual(ones, 64)
assertEqual(bits, 128)
_, net, err = ParseCIDR("2001::0db8/96")
if err != nil {
panic(err)
}
ones, bits = net.Size()
assertEqual(ones, 96)
assertEqual(bits, 128)
}
func TestMasking(t *testing.T) {
for _, ipstr := range testIPStrs {
doMaskingTest(easyParseIP(ipstr), t)
}
}
func TestMaskingFuzz(t *testing.T) {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
buf := make([]byte, 4)
for i := 0; i < 10000; i++ {
r.Read(buf)
doMaskingTest(net.IP(buf), t)
}
buf = make([]byte, 16)
for i := 0; i < 10000; i++ {
r.Read(buf)
doMaskingTest(net.IP(buf), t)
}
}
func BenchmarkMasking(b *testing.B) {
ip := easyParseIP("2001:0db8::42")
flat := FromNetIP(ip)
b.ResetTimer()
for i := 0; i < b.N; i++ {
flat.Mask(64, 128)
}
}
func BenchmarkMaskingLegacy(b *testing.B) {
ip := easyParseIP("2001:0db8::42")
mask := net.CIDRMask(64, 128)
b.ResetTimer()
for i := 0; i < b.N; i++ {
ip.Mask(mask)
}
}
func BenchmarkMaskingCached(b *testing.B) {
i := easyParseIP("2001:0db8::42")
flat := FromNetIP(i)
mask := cidrMask(64, 128)
b.ResetTimer()
for i := 0; i < b.N; i++ {
flat.applyMask(mask)
}
}
func BenchmarkMaskingConstruct(b *testing.B) {
for i := 0; i < b.N; i++ {
cidrMask(69, 128)
}
}
func BenchmarkContains(b *testing.B) {
ip := easyParseIP("2001:0db8::42")
flat := FromNetIP(ip)
_, ipnet, err := net.ParseCIDR("2001:0db8::/64")
if err != nil {
panic(err)
}
flatnet := FromNetIPNet(*ipnet)
b.ResetTimer()
for i := 0; i < b.N; i++ {
flatnet.Contains(flat)
}
}
func BenchmarkContainsLegacy(b *testing.B) {
ip := easyParseIP("2001:0db8::42")
_, ipnetptr, err := net.ParseCIDR("2001:0db8::/64")
if err != nil {
panic(err)
}
ipnet := *ipnetptr
b.ResetTimer()
for i := 0; i < b.N; i++ {
ipnet.Contains(ip)
}
}

View File

@ -1,24 +0,0 @@
//go:build !(plan9 || solaris)
package flock
import (
"errors"
"github.com/gofrs/flock"
)
var (
CouldntAcquire = errors.New("Couldn't acquire flock (is another Ergo running?)")
)
func TryAcquireFlock(path string) (fl Flocker, err error) {
f := flock.New(path)
success, err := f.TryLock()
if err != nil {
return nil, err
} else if !success {
return nil, CouldntAcquire
}
return f, nil
}

View File

@ -1,14 +0,0 @@
package flock
// documentation for github.com/gofrs/flock incorrectly claims that
// Flock implements sync.Locker; it does not because the Unlock method
// has a return type (err).
type Flocker interface {
Unlock() error
}
type noopFlocker struct{}
func (n *noopFlocker) Unlock() error {
return nil
}

View File

@ -1,7 +0,0 @@
//go:build plan9 || solaris
package flock
func TryAcquireFlock(path string) (fl Flocker, err error) {
return &noopFlocker{}, nil
}

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