Initial public commit

This commit is contained in:
Jon 2022-02-20 14:43:11 +00:00
commit 0f3b5a6bac
17 changed files with 969 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
keyring.gpg
ignored.txt
0_secrets.yaml
*~
~*
.#*

9
config.d/1_config.yaml Normal file
View File

@ -0,0 +1,9 @@
base_url: https://glowers.club/_matrix
synapse_base_url: http://nowhere.local/_synapse
user_id: '@test:glowers.club'
keyring: ./keyring.gpg
audit_room: '!GzPMBYkwPFXlIwuYlH:glowers.club'
dry_run: true
policy_rooms:
#- '!GzPMBYkwPFXlIwuYlH:glowers.club'
- '!iINqXGsJxTyFNZOCQK:glowers.club'

View File

@ -0,0 +1,18 @@
actions:
- tag: high_risk_porn
room:
archive:
- media
- members
delist: true
shutdown: true
silent: true
notify: true
quarantine: true
user:
archive:
- media
- rooms
deactivate: true
notify: true
quarantine: true

View File

@ -0,0 +1,6 @@
actions:
- tag: nsfw
room:
delist: true
shutdown: true
reason: 'This room has been closed due to being in violation of our acceptable usage policy.'

145
docs/debian_11.md Normal file
View File

@ -0,0 +1,145 @@
**NOTE:** This is out of date and will need to be updated.
# Debian 11 Install Guide for hotpocket
The following guide is intended for Debian or debian-like (Ubuntu) distributions. Non-debian users may follow this guide as well, but they may need to perform additional steps during setup.
## Dependencies
Hotpocket requires `curl`, `mktemp`, `gpg`, `jq`, and `yq` to run.
`mktemp` should already be available on your system. You can install `curl`, `gpg`, and `jq` from the debian repository
```sh
$ apt install curl gpg jq
```
`curl`, `gpg`, and `jq` should now appear in your environment.
```sh
$ which curl gpg jq
/usr/bin/curl
/usr/bin/gpg
/usr/bin/jq
```
To install `yq`, the `yq` developers suggest you use a an ubuntu ppa. You may also install `yq` through `pip3` by running `pip3 install yq`
```sh
$ apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 9a2d61f6bb03ced7522b8e7d6657dbe0cc86bb64
$ echo 'http://ppa.launchpad.net/rmescandon/yq/ubuntu focal main' > /etc/apt/sources.list.d/rmescandon-ubuntu-yq-focal.list
```
Running `apt update` should now show the PPA among your sources.
```
$ apt update
Get:1 http://ppa.launchpad.net/rmescandon/yq/ubuntu focal InRelease [18.0 kB]
Hit:2 http://deb.debian.org/debian bullseye InRelease
Hit:3 http://security.debian.org/debian-security bullseye-security InRelease
Hit:4 http://deb.debian.org/debian bullseye-updates InRelease
Get:5 http://ppa.launchpad.net/rmescandon/yq/ubuntu focal/main amd64 Packages [488 B]
Get:6 http://ppa.launchpad.net/rmescandon/yq/ubuntu focal Translation-en [264 B]
Fetched 18.8 kB in 1s (29.1 kB/s)
```
You should now be able to install `yq`.
```sh
$ apt install yq
```
Once installed, `yq` should appear in your environment.
```sh
$ which yq
/usr/bin/yq
```
## Installation
Create a `hotpocket` user, and a `hotpocket-data` group.
```sh
$ groupadd hotpocket-data -r
$ useradd hotpocket -g hotpocket-data -d /etc/hotpocket -s /usr/sbin/nologin -MNr
```
You can check `/etc/passwd` and `/etc/shadow` to make sure that the user is properly configured.
*The UID of the hotpocket user will likely be different*
```sh
$ cat /etc/passwd | grep hotpocket
hotpocket:x:998:998::/etc/hotpocket:/usr/sbin/nologin
$ cat /etc/shadow | grep hotpocket
hotpocket:!:19020::::::
$ groups hotpocket
hotpocket : hotpocket-data
```
Next, create the `hotpocket` directory in `etc`.
```sh
$ mkdir /etc/hotpocket
$ chown root:hotpocket-data /etc/hotpocket
$ chmod 750 /etc/hotpocket
```
Your new directory should look like this:
```sh
$ ls -l /etc | grep hotpocket
drwxr-x--- 2 root hotpocket-data 4096 Jan 28 05:50 hotpocket
```
Next, copy in the supplied `config.yaml`, `secrets.yaml`, and `hotpocket.sh`. You do not need to copy `mkpolicy.sh`, you may store that elsewhere.
```sh
$ cd /etc/hotpocket
$ cp /mnt/hotpocket/*.yaml /mnt/hotpocket/hotpocket.sh .
$ touch keyring.gpg
$ chown root:hotpocket-data *
$ chmod 640 *
$ chmod 650 hotpocket.sh
```
Your file permissions should look like this:
```sh
$ ls -l
drw-r----- 1 root hotpocket-data 218 Jan 28 05:52 config.yaml
drw-r-x--- 1 root hotpocket-data 5671 Jan 28 05:52 hotpocket.sh
drw-r----- 1 root hotpocket-data 0 Jan 28 05:52 keyring.gpg
drw-r----- 1 root hotpocket-data 55 Jan 28 05:52 secrets.yaml
```
Next, we're going to want to change some values in `config.yaml` and `secrets.yaml`.
You'll need to change `base_url`, `synapse_base_url`, and `policy_rooms` to sensible values. Ensure that the `base_url` and `synapse_base_url` do not end with `/`.
You will also need to create a synapse admin account for hotpocket to use, then to fill in the `access_token` in `secrets.yaml`. Do not include the `"Bearer "` prefix!
Once done, you can begin setting up your keyring.
## Keyring setup
Hotpocket requires policies to be signed, hotpocket uses `gpg` to validate any policies it finds in your defined policy rooms.
As the user which owns `keyring.gpg` (root in this case), add Jon's public key to the keyring.
```
$ # The hotpocket archive should include the `jon_at_glowers_club.asc` public key.
$ gpg --no-default-keyring --keyring "$PWD/keyring.gpg" --import /mnt/hotpocket/jon_at_glowers_club.asc
gpg: key 1A4A0CC4CE53281B public key "Jonathan (@jon:glowers.club) <[email protected]>" imported
gpg: Total number processed: 1
gpg: imported: 1
$ gpg --no-default-keyring --keyring "$PWD/keyring.gpg" --list-keys
./keyring.gpg
-------------
pub rsa4096 2022-01-27 [SC] [expires: 2025-01-11]
5C5E17B334E084FE822007D71A4A0CC4CE53281B
uid [ unknown] Jonathan (@jon:glowers.club) <[email protected]>
sub rsa5096 2022-01-27 [E] [expires: 2025-01-11]
```
At this stage you may also wish to import your own public key, or the public keys of other policy rooms admins.

BIN
docs/hotpocket.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

28
docs/tags.md Normal file
View File

@ -0,0 +1,28 @@
# Standardized tags
The following tags are defined to better ensure intercompatibility between hotpocket policies deployed by different administrators.
| Tag | Liability | Description
--- | --- | ---
| `csam` | Front Door | Child Sexual Abuse Material. i.e: cp
| `jailbait` | High <sup title="Jailbait comprises of legal images of minors, such as child models, however the collection of such images is highly suspect. Jailbait rooms tend to include users who trade CSAM. At minimum, these rooms are a heavy liability">?</sup> | Suggestive images of minors, not technically `casm` but contextually borderline.
| `beastiality` | High <sup title="See local laws">?</sup> | Pornography with features animals.
| `3d_loli` | High <sup title="Questionably legal in the west. As 3D loli tends to be realistically stylised, it may be illegal to posses. See local laws">?</sup> | 3DCG suggestive or explicit depictions of fictional minors.
| `loli` | Moderate <sup title="Questionably legal in the west. See local laws">?</sup> | Drawn suggestive or explicit depictions of fictional minors.
| `anarchy` | Moderate | Rooms which have no room admins or moderators.
| `irl_porn` | Moderate <sup title="Legal with few exceptions, see local laws.">?</sup> | Pornography which features real people.
| `drawn_porn` | Low | Pornography which depicts fictional characters.
| `gore` | Low | Rooms which exists to host gore.
| `commerical` | Low | Rooms which exist exclusively to promote a specific commerical product or group.
| `spam` | Low | Rooms which exist primarily to host spam content.
## Topics:
These additional tags apply to groups of tags. See `scripts/schema/topics.yaml`
| Topic | Description
--- | ---
| `high_risk_porn` | Similar to `high_risk`, limited to porn.
| `high_risk` | Content which has a high risk to host.
| `porn` | Any kind of pornagraphic content.
| `nsfl` | `gore` + `high_risk`
| `nsfw` | `porn` + `nsfl`

438
hotpocket.sh Executable file
View File

@ -0,0 +1,438 @@
#!/usr/bin/env bash
VERSION="1.0.0"
CONFIG_DIR="$HOTPOCKET_CONFIG_DIR"
set -u
set +H
# This will be removed, dont rely on it
[ -z "$CONFIG_DIR" ] && echo "main: HOTPOCKET_CONFIG_DIR is not defined, assuming working directory. This is potentially unsafe"
[ -z "$CONFIG_DIR" ] && CONFIG_DIR="$PWD"
SCHEMA_DIR="$CONFIG_DIR/scripts/schema"
CONFIGD_DIR="$CONFIG_DIR/config.d"
IGNORED_FILE="$CONFIG_DIR/ignored.txt"
echo "main: Running as $(whoami). Version $VERSION. Directory at $CONFIG_DIR"
die () { echo "$@" 1>&2; exit 1; }
stripCtrlChars () { sed 's/\x1b/\\x1b/g' /dev/stdin; return $?; }
[ -d "$CONFIG_DIR" ] || die "main: No hotpocket directory defined! See readme."
[ -d "$SCHEMA_DIR" ] || die "main: No schema directory defined! See readme."
[ -d "$CONFIGD_DIR" ] || die "main: No config.d directory defined! See readme."
[ -f "$IGNORED_FILE" ] || die "main: No ignored.txt exists! You'll need to create one that $(whoami) can write to."
str_startswith () {
# str_startsswith: prefix string
local alen
local blen
local end
alen=${#1}
blen=${#2}
if [ "$alen" -gt "$blen" ]; then
return 1
else
if [ "$1" == "${2:0:$alen}" ]; then
return 0
else
return 1
fi
fi
}
str_endswith () {
# str_endswith: affix string
local alen
local blen
local end
alen=${#1}
blen=${#2}
if [ "$alen" -gt "$blen" ]; then
return 1
else
end=$((blen - alen))
if [ "$1" == "${2:$end:$alen}" ]; then
return 0
else
return 1
fi
fi
}
# Merge all files in config.d/
createConfig () {
# createConfig:
local filepath
local filebase
local json
local code
local config
config="{}"
for filepath in "$CONFIGD_DIR/"*; do
filebase="$(basename "$filepath")"
if [ -d "$filepath" ]; then
echo "createConfig: Ignoring $filebase, Is a directory." 1>&2
elif str_startswith "~" "$filebase" || str_endswith "~" "$filebase"; then
echo "createConfig: Ignoring $filebase. Is a temporary file!" 1>&2
elif str_startswith "." "$filebase"; then
echo "createConfig: Ignoring $filebase. Is a hidden file!" 1>&2
elif str_endswith ".yml" "$filebase" || str_endswith ".yaml" "$filebase" || str_endswith ".json" "$filebase"; then
json="$(yq . "$filepath" -c)"
code="$?"
if [ $code -eq 0 ]; then
config="$(jq --argjson a "$config" --argjson b "$json" \
'$a * $b * {
policy_rooms: ($a.policy_rooms + $b.policy_rooms),
actions: ($a.actions + $b.actions)
}' -nc)" || die "createConfig: Failed to merge $filebase with in-memory config. Exited with code $?"
else
echo "createConfig: Failed to load $filebase. Exited with code $code" 1>&2
exit $code
fi
else
echo "createConfig: Ignoring $(basename "$filebase")." 1>&2
fi
done
#jq . 1>&2 <<< "$config"
jq . <<< "$config"
}
CONFIG="$(createConfig)" || die "main: Failed to load config.d! Exited with $?"
yajsv -q -s "$SCHEMA_DIR/config.yaml" /dev/stdin <<< "$CONFIG" || die "main: Failed to load config.yaml! Defined config does not validate against schema"
BASE_URL="$(jq '.base_url' -r <<< "$CONFIG")" || die "main: Failed to load config.yaml! Failed to read base_url"
SYNAPSE_BASE_URL="$(jq '.synapse_base_url' -r <<< "$CONFIG")" || die "main: Failed to load config.yaml! Failed to read synapse_base_url"
KEYRING_FILE="$(jq '.keyring' -r <<< "$CONFIG")" || die "main: Failed to load config.yaml! Failed to read keyring"
KEYRING_FILE="$(realpath "$(realpath --relative-to="$CONFIG_DIR" "$KEYRING_FILE")")"
[ -f "$KEYRING_FILE" ] || die "main: Defined keyring does not exist! Should exist at $KEYRING_FILE"
HAS_AUDIT_ROOM=""; jq -e '.audit_room | type == "string"' 1>/dev/null <<< "$CONFIG" && HAS_AUDIT_ROOM="1"
DRY_RUN=""; jq -e '.dry_run' 1>/dev/null <<< "$CONFIG" && DRY_RUN="1"
ACTIONS="$(jq .actions -c <<< "$CONFIG")" || die "main: Failed to load config.yaml! Failed to read actions"
ACCESS_TOKEN="$(jq '.access_token' -r <<< "$CONFIG")" || die "main: Failed to read access_token!"
USER_ID="$(jq '.user_id' -r <<< "$CONFIG")" || die "main: Failed to read user_id!"
TOPICS="$(yq -c . "$SCHEMA_DIR/topics.yaml")" || die "main: Failed to load topics!"
echo "main: This is a dry run, any actions will fail!" 1>&2
readonly CONFIG
readonly ACTIONS
readonly ACCESS_TOKEN
readonly TOPICS
readonly USER_ID
readonly DRY_RUN
fetchRoomState () {
# fetchRoomState: room_id
local room_id
room_id="$(jq '@uri' -Rr <<< "$1")" || return 1
curl -sf -H "Authorization: Bearer $ACCESS_TOKEN" "$BASE_URL/client/r0/rooms/$room_id/state"
return $?
}
fetchRoomPolicies () {
# fetchRoomPolicies: room_id
local states
states="$(fetchRoomState "$1")" || return 1
jq '[ .[] | select(.type == "club.glowers.policy.rule.collection") ]' -c <<< "$states"
return $?
}
arrayIndexList () {
# eachInArray: array
local length
length="$(jq 'length' -r <<< "$1")"
if [ -n "$length" ]; then
seq 1 "$length"
fi
}
# satan
# TODO: Rewrite this fucking abomination as a jq filter
arrayAndIncludes () {
# arrayAndIncludes: Array<String> Array<String>
local bset
local aset_index ; local a
local bset_index ; local b
bset="$(arrayIndexList "$2")"
for aset_index in $(arrayIndexList "$1"); do
a="$(jq ".[$((aset_index-1))]" -rc <<< "$1")"
for bset_index in $bset; do
b="$(jq ".[$((bset_index-1))]" -rc <<< "$2")"
if [ "$a" == "$b" ]; then
echo "$a"
return 0
fi
done
done
return 1
}
# shellcheck disable=SC2155
genIgnorefileLine () {
# genIgnorefileLine: state
local room_id="$(jq .room_id -r <<< "$1")"
local state_key="$(jq .state_key -r <<< "$1")"
echo "$room_id $state_key"
}
checkPolicyIgnored () {
# checkPolicyIgnored: state
grep -Fx "$(genIgnorefileLine "$1")" "$IGNORED_FILE" 1>/dev/null # || return 0
}
checkSignature () {
# checkSignature: state
local content
local signature
local mask
local wd
local code
content="$(jq '.content | { rules: .rules, signature: null }' -c <<< "$1")" || return 1
signature="$(jq .content.signature -r <<< "$1")" || return 2
mask="$(umask)"
umask 0077
wd="$(mktemp -d)" || return 3
echo "$signature" > "$wd/content.json.sig"
echo "$content" > "$wd/content.json"
umask "$mask"
gpg --no-default-keyring --keyring "$KEYRING_FILE" --verify "$wd/content.json.sig"
code="$?"
if [ "$code" != "0" ]; then
echo "checkSignature: Exception when verifying policy signature. Exit code: $?" 1>&2
rm "$wd/content.json.sig" "$wd/content.json"
rmdir "$wd"
return 4
fi
rm "$wd/content.json.sig" "$wd/content.json"
rmdir "$wd"
return 0
}
checkPolicyAgainstSchema () {
# checkPolicyAgainstSchema: policy
yajsv -q -s "$SCHEMA_DIR/policy.yaml" /dev/stdin <<< "$(jq .content -c <<< "$1")"
}
oneOfTagsAsArray () {
# oneOfTagsAsArray: policy
jq 'if (.tag | type == "string") then [.tag] else if (.tags | type == "array") then .tags else [] end end' -c < /dev/stdin
}
applyTopicsToTags () {
# applyTopicsToTags:
jq --argjson aliases "$TOPICS" '[foreach ($aliases | to_entries[]) as $alias (.; if any($alias.value[] == .) then . + [$alias.key] else . end)] | last' --< /dev/stdin
return $?
}
deployRoomShutdown () {
# deployRoomShutdown: room_id action
[ -n "$DRY_RUN" ] && return 0
local room_id
local status
local payload
local reason
room_id="$(jq '@uri' -Rr <<< "$1")" || return 1
# FIXME: No proper error handling if an error thrown here.
status="$(curl -sf -H "Authorization: Bearer $ACCESS_TOKEN" "$SYNAPSE_BASE_URL/admin/v1/rooms/$room_id/block")" || return 1
#status='{"block": false}'
if ! jq -e '.block' 1>/dev/null <<< "$status"; then
payload='{"block": true}'
# TODO: Add flag for .purge?
if ! jq -e '.room.silent' 1>/dev/null <<< "$2"; then
payload="$(jq --argjson pl "$payload" --arg user_id "$USER_ID" '$pl * { new_room_user_id: $user_id }' -nc)"
fi
reason="$(jq '.room.reason' -r <<< "$2")"
if jq -e '.room.reason' 1>/dev/null <<< "$2"; then
payload="$(jq --argjson pl "$payload" --arg reason "$reason" '$pl * { reason: $reason }' -nc)"
fi
#echo "$payload" 1>&2
curl -sf -X DELETE -d "$payload" \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" "$SYNAPSE_BASE_URL/admin/v1/rooms/$room_id/block"
return $?
fi
}
deployRoomDelist () {
# deployRoomDelist: room_id action
[ -n "$DRY_RUN" ] && return 0
local room_id
room_id="$(jq '@uri' -Rr <<< "$1")" || return 1
# FIXME: No error handling if an error thrown here.
# TODO: Check if synapse ACTUALLY lets you do this. Docs say you can but who travis probably wrote them
curl -sf -X PUT -d '{"visibility":"private"}' \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" "$BASE_URL/client/r0/directory/list/room/$room_id"
return $?
}
deployRoomQuarantine () {
# deployRoomQuarantine: room_id action
[ -n "$DRY_RUN" ] && return 0
local room_id
room_id="$(jq '@uri' -Rr <<< "$1")" || return 1
# FIXME: No error handling if an error thrown here.
curl -sf -X POST -d '{}' -H "Authorization: Bearer $ACCESS_TOKEN" "$SYNAPSE_BASE_URL/admin/v1/room/$room_id/media/quarantine"
return $?
}
deployRoomRule () {
# deployRoomRule: room_id action
local action_taken
local code
# TODO: Does synapse delist if shutting down? If so we should just skip delist if shutdown is defined
if jq -e '.room.delist' 1>/dev/null <<< "$2"; then
action_taken='1'
deployRoomDelist "$1" "$2"
# TODO: Failure shouldn't block quarantine or shutdown, not the best way of handling.
#code="$?"
#[ "$code" -ne 0 ] && return $code
fi
if jq -e '.room.quarantine' 1>/dev/null <<< "$2"; then
action_taken='1'
deployRoomQuarantine "$1" "$2"
code="$?"
[ "$code" -ne 0 ] && return $code
fi
if jq -e '.room.shutdown' 1>/dev/null <<< "$2"; then
action_taken='1'
deployRoomShutdown "$1" "$2"
code="$?"
[ "$code" -ne 0 ] && return $code
fi
[ -z "$action_taken" ] && echo "deployRoomRule: Action defines no action. Odd" 2>&1
return 0
}
deployUserRule () {
# deployUserRule: user_id action
echo "deployUserRule: Not Implemented!" 1>&2
return 255
}
deployRule () {
# deployRule: rule action
local type
type="$(jq .type -r <<< "$1")"
if [ "$type" == "m.user" ]; then
deployUserRule "$1" "$2"
return $?
elif [ "$type" == "m.room" ]; then
deployRoomRule "$1" "$2"
return $?
else
die "deployRule: Internal logic error. Schema check let something slip through!"
fi
}
ingestRule () {
# ingestRule: rule index state_key
local rule_tags
local actions_index
local action
local action_tags
local match
local code
rule_tags="$(oneOfTagsAsArray <<< "$1" | applyTopicsToTags)" || return 1
for actions_index in $(arrayIndexList "$ACTIONS"); do
action="$(jq ".[$((actions_index-1))]" -rc <<< "$ACTIONS")" || return 2
action_tags="$(oneOfTagsAsArray <<< "$action" | applyTopicsToTags)" || return 3
match="$(arrayAndIncludes "$rule_tags" "$action_tags")"
if [ -n "$match" ]; then
echo "ingestRule[$3:$2]: Found matching tag \"$match\"."
deployRule "$1" "$action"
code="$?"
[ "$code" -ne 0 ] && echo "ingestRule[$3:$2]: Unexpected exit code of $code when handling action." 1>&2
return $code
fi
done
}
ingestPolicy () {
# ingestPolicy: state state_key
local rule
local retcode="0"
local rules
rules="$(jq .content.rules -c <<< "$1")"
for policy_index in $(arrayIndexList "$rules"); do
rule="$(jq ".[$((policy_index-1))]" -rc <<< "$rules")" || return 1
ingestRule "$rule" "$policy_index" "$2"
local code="$?"
if [ "$code" != "0" ]; then
echo "ingestPolicy[$policy_index]: Unexpected exit code of $code when handling rule."
local retcode="2"
fi
done
return $retcode
}
ingestState () {
# ingestState: state
local state_key
state_key="$(stripCtrlChars <<< "$(jq .state_key -r <<< "$1")")"
if ! checkPolicyIgnored "$1"; then
if checkPolicyAgainstSchema "$1"; then
if checkSignature "$1"; then
ingestPolicy "$1" "$state_key" && genIgnorefileLine "$1" >> "$IGNORED_FILE"
else
echo "ingestState[$state_key]: Unexpected GnuPG exit code $?." 1>&2
fi
else
echo "ingestState[$state_key]: Unexpected yajsv exit code $?. This policy is improperly formatted!" 1>&2
genIgnorefileLine "$1" >> "$IGNORED_FILE"
fi
else
echo "ingestState[$state_key]: Ignored."
fi
}
ingestPolicyRoom () {
# ingestPolicyRoom: room_id
local policies
local state
policies="$(fetchRoomPolicies "$1")" || return 1
for policies_index in $(arrayIndexList "$policies"); do
state="$(jq ".[$((policies_index-1))]" -rc <<< "$policies")" || return 2
ingestState "$state" || echo "ingestPolicyRoom[$1]: Unexpected exit code of $? when processing state." 1>&2
done
}
init () {
local room_id
local policy_rooms_index
for policy_rooms_index in $(arrayIndexList "$(jq '.policy_rooms' -c <<< "$CONFIG")"); do
room_id="$(jq ".policy_rooms[$((policy_rooms_index-1))]" -r <<< "$CONFIG")" || return 1
echo "main: Requesting policies for $room_id"
ingestPolicyRoom "$room_id" || die "init[$room_id]: Unexpected exit code when processing policy room. $?"
# processPolicyRoom "$room_id"
#code="$?"
#if [ "$code" != "0" ]; then echo "main: Unexpected exit code when processing policy room \"$room_id\": $code" 1>&2; fi
done
}
init

45
readme.md Normal file
View File

@ -0,0 +1,45 @@
**NOTE:** Hotpocket is still a work in progress, the documentation and the script are not to be trusted.
<center>
<br />
<!-- Apostrophe doesn't render this properly because it's retarded. WONTFIX -->
<img src="docs/hotpocket.png" alt="Hotpocket" />
<br /><br />
<p>Daemon for automated room management</p>
<a href="docs/debian_11.md">Installation</a> |
<a href="docs/configuration.md">Configuration</a> |
<a href="docs/policies.md">Policy deployment</a> |
<a href="docs/tags.md">Tags</a> |
<a href="https://matrix.to/#/#loj:glowers.club">#loj:glowers.club</a>
<br /><br />
</center>
Hotpocket is a room management daemon for matrix, which allows homeserver operators to more effectively deal with unwanted content.
### Todo
- [x] Logo (very important)
- [ ] Documentation. Everything changed needs lookover
- [x] config.d
- [x] Policy querying
- [x] Basic Actions
- [ ] Advanced Actions (archiving, audit log)
- [ ] Audit log. This *was* implemented but everything got rewritten
- [ ] Mjolnir forwarding
### Faq
#### Why?
Existing tools like Mjolnir do not implement `m.policy.rule.room`, meaning room management through Mjolnir is tedious and inefficient. Additionally, Mjolnir can only act on one homeserver at a time, meaning homeserver operators can't effectively crowd-source homeserver moderation.
#### Why in bash?
Hotpocket is intended to be easily deployable, with as few dependencies as possible. Shell scripts are not only highly portable, but easily auditable.
#### Is this more complicated than Mjolnir?
The only effective difference between Mjolnir's room management and Hotpocket, is that hotpocket requires all policies to be signed by a trusted public key.
If you're running your own homeserver, the small jump in complexity shouldn't be an issue.
#### Can I use this with my <attr title="Element Matrix Services">EMS</attr> homeserver?
If you want to run hotpocket on a <attr title="Matrix-as-a-Service">MaaS</attr> provider, you will need access to `/_synapse` and a server to install hotpocket to.
As `/_synapse` will be exposed to the internet, this is not considered a secure configuration. If possible, you should migrate to hosting your matrix homeserver yourself.

58
scripts/polmake.sh Executable file
View File

@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -u
if [ -z "$EDITOR" ]; then echo "polmake: No EDITOR set in environment. Please set one :)" 1>&2; exit 1; fi
ext=".yaml"
newfile () {
local prev_mask="$(umask)"
umask 0077
local file="$(mktemp --suffix "$ext")"
umask "$prev_mask"
echo "$file"
}
extswap () {
local text="$(cat < /dev/stdin)"
if [ "$ext" == ".yaml" ]; then
echo "$text" | yq .
elif [ "$ext" == ".json" ]; then
echo "$text"
else
echo "polmake: Unsure how to process extension typeof \"$ext\"." 1>&2
exit 1
fi
}
file="$(newfile)"
while :; do
set +u
$EDITOR "$file"
set -u
json="$(cat "$file")"
code="$?"
if [ "$code" != 0 ]; then exit 0; fi
if [ -z "$json" ]; then exit 0; fi
json="$(echo "$json" | extswap)"
echo "$json
Type EDIT to open the editor
Type SIGN to sign this policy
Type QUIT to cancel"
read -p "> " cmd
if [ "$cmd" == "EDIT" ] || [ "$cmd" == "edit" ] || [ "$cmd" == "e" ]; then
continue
elif [ "$cmd" == "SIGN" ] || [ "$cmd" == "sign" ] || [ "$cmd" == "s" ]; then
rm "$file"
read -p "gpgid> " gpgid
./polsign.sh "$gpgid" <<< "$json"
break
else
rm "$file"
break
fi
done

24
scripts/polsign.sh Executable file
View File

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -u
KEY_ID="$1"
if [ -z "$KEY_ID" ]; then echo "polsign: A signing key id must be defined!" 1>&2; exit 255; fi
# echo "{}" | mkpolicy key_id
JSON="$(jq . < /dev/stdin)"
if [ "$?" != "0" ]; then echo "polsign: Unexpected exit code when loading JSON from stdin: $?" 1>&2; exit 255; fi
_JSON="$(echo "$JSON" | jq '{ rules: .rules, signature: null }' -c)"
if [ "$?" != "0" ]; then echo "polsign: Unexpected exit code when stripping JSON: $?" 1>&2; exit 255; fi
SIGNATURE="$(echo "$_JSON" | gpg --local-user "$KEY_ID" --sign --armor --detach-sig)"
if [ "$?" != "0" ]; then echo "polsign: Unexpected exit code when signing JSON: $?" 1>&2; exit 255; fi
SIGNED="$(jq --null-input \
--argjson event "$_JSON" \
--arg signature "$SIGNATURE" \
'$event * { signature: $signature }')"
if [ "$?" != "0" ]; then echo "polsign: Unexpected exit code when signing JSON: $?" 1>&2; exit 255; fi
echo "$SIGNED"

109
scripts/schema/config.yaml Normal file
View File

@ -0,0 +1,109 @@
title: config.yaml
type: object
required: [ access_token, user_id, base_url, synapse_base_url, keyring, policy_rooms, actions ]
properties:
base_url:
type: string
example: http://127.0.0.1:3080/_matrix
format: uri
pattern: ^https?://.+/_matrix$
synapse_base_url:
type: string
example: http://127.0.0.1:3880/_synapse
format: uri
pattern: ^https?://.+/_synapse$
keyring:
type: string
example: ./keyring.gpg
access_token:
type: string
dry_run:
type: boolean
user_id:
comment: 'The user_id tied to the access_token. Used for room shutdowns'
type: string
pattern: '@[\w\-]+:.+'
policy_rooms:
type: array
items:
type: string
pattern: '![\w\-]+:.+'
actions:
type: array
items:
type: object
oneOf:
- required: [ tag ]
properties:
tag: { type: string, pattern: ^\w+$ }
- required: [ tags ]
properties:
tags:
type: array
items: [ { type: string, pattern: ^\w+$ } ]
anyOf:
- required: [ user ]
- required: [ room ]
properties:
user:
properties:
archive:
properties:
media:
comment: 'Save a copy of the identifiers for the last 255 media uploads by this user to disk. This does not save the actual uploads'
type: boolean
rooms:
comment: 'Save a copy of all rooms this user was participating in to disk.'
type: boolean
deactivate:
type: boolean
comment: 'Deactivate the User'
notify:
comment: 'Create an audit log in the audit room about the user.'
type: boolean
quarantine:
comment: 'Quarantine all media uploaded by the user.'
type: boolean
# TODO: Add mjolnir list forwarding
room:
properties:
archive:
properties:
media:
comment: 'Save a copy of the identifiers for the last 255 media uploads in this room to disk. This does not save the actual uploads'
type: boolean
members:
comment: 'Save a copy of all users that were participating in this room to disk.'
type: boolean
delist:
comment: 'Remove the room from the room directory.'
type: boolean
notify:
comment: 'Create an audit log in the audit room about this room.'
type: boolean
reason:
comment: 'For shutdown. A publically visible reason.'
type: string
shutdown:
comment: 'Shutdown the room'
type: boolean
silent:
comment: 'For shutdown. If a notice room should not be created for the closed room.'
type: boolean
quarantine:
comment: 'Quarantine all media uploaded in the room.'
type: boolean

View File

@ -0,0 +1,15 @@
{
"rules": [
{
"type": "m.room",
"entity": "!id:example.com",
"tags": [ "csam" ]
},
{
"type": "m.user",
"entity": "@user-_:example.com",
"tag": "high_risk"
}
],
"signature": "-----BEGIN PUBLIC KEY SIGNATURE-----..."
}

View File

@ -0,0 +1,28 @@
title: policy.yaml
type: object
required: [ signature, rules ]
properties:
signature:
type: string
rules:
type: array
items:
type: object
required: [ type, entity ]
allOf:
- oneOf:
- properties:
type: { type: string, const: m.room }
entity: { type: string, pattern: '^![\w\-]+:.+$' }
- properties:
type: { type: string, const: m.user }
entity: { type: string, pattern: '^@[\w\-]+:.+$' }
- oneOf:
- required: [ tag ]
properties:
tag: { type: string, pattern: ^\w+$ }
- required: [ tags ]
properties:
tags:
type: array
items: [ { type: string, pattern: ^\w+$ } ]

View File

@ -0,0 +1,22 @@
# This file contains a list of "tag aliases", called topics.
# If any entry values are found, the entry key is added to the tags.
# This is mainly used for making actions less verbose
high_risk_porn:
- 3d_loli
- csam
- beastiality
high_risk:
- anarchy
- jailbait
- high_risk_porn
porn:
- drawn_porn
- high_risk_porn
- irl_porn
- loli
nsfl:
- gore
- high_risk
nsfw:
- porn
- nsfl

View File

@ -0,0 +1,9 @@
[Unit]
Description=Matrix Homeserver Policy Manager
Wants=hotpocket.timer
[Service]
User=hotpocket
Type=simple
Environment="HOTPOCKET_CONFIG_DIR=/etc/hotpocket"
ExecStart=./etc/hotpocket.sh

View File

@ -0,0 +1,9 @@
[Unit]
Description=Hotpocket Timer
[Timer]
OnBootSec=30s
OnCalendar=*:0/10
[Install]
WantedBy=timers.target