commit 0f3b5a6bac70b3b6d12c49a64a2d6b1c76ce7aad Author: Jon Date: Sun Feb 20 14:43:11 2022 +0000 Initial public commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..643c481 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +keyring.gpg +ignored.txt +0_secrets.yaml +*~ +~* +.#* diff --git a/config.d/1_config.yaml b/config.d/1_config.yaml new file mode 100644 index 0000000..170771c --- /dev/null +++ b/config.d/1_config.yaml @@ -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' diff --git a/config.d/2_action_high_risk_porn.yaml b/config.d/2_action_high_risk_porn.yaml new file mode 100644 index 0000000..a1b6a00 --- /dev/null +++ b/config.d/2_action_high_risk_porn.yaml @@ -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 diff --git a/config.d/3_action_nsfw.yaml b/config.d/3_action_nsfw.yaml new file mode 100644 index 0000000..e25bf69 --- /dev/null +++ b/config.d/3_action_nsfw.yaml @@ -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.' diff --git a/docs/debian_11.md b/docs/debian_11.md new file mode 100644 index 0000000..36795a3 --- /dev/null +++ b/docs/debian_11.md @@ -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. diff --git a/docs/hotpocket.png b/docs/hotpocket.png new file mode 100644 index 0000000..86ac97e Binary files /dev/null and b/docs/hotpocket.png differ diff --git a/docs/tags.md b/docs/tags.md new file mode 100644 index 0000000..8f4a8ac --- /dev/null +++ b/docs/tags.md @@ -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 ? | Suggestive images of minors, not technically `casm` but contextually borderline. +| `beastiality` | High ? | Pornography with features animals. +| `3d_loli` | High ? | 3DCG suggestive or explicit depictions of fictional minors. +| `loli` | Moderate ? | Drawn suggestive or explicit depictions of fictional minors. +| `anarchy` | Moderate | Rooms which have no room admins or moderators. +| `irl_porn` | Moderate ? | 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` diff --git a/hotpocket.sh b/hotpocket.sh new file mode 100755 index 0000000..31ef852 --- /dev/null +++ b/hotpocket.sh @@ -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 Array + 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 diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..7cb0ad9 --- /dev/null +++ b/readme.md @@ -0,0 +1,45 @@ +**NOTE:** Hotpocket is still a work in progress, the documentation and the script are not to be trusted. + +
+
+ +Hotpocket +

+

Daemon for automated room management

+Installation | +Configuration | +Policy deployment | +Tags | +#loj:glowers.club +

+
+ +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 EMS homeserver? +If you want to run hotpocket on a MaaS 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. diff --git a/scripts/polmake.sh b/scripts/polmake.sh new file mode 100755 index 0000000..5975a7a --- /dev/null +++ b/scripts/polmake.sh @@ -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 + + + + diff --git a/scripts/polsign.sh b/scripts/polsign.sh new file mode 100755 index 0000000..1cb058d --- /dev/null +++ b/scripts/polsign.sh @@ -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" + + diff --git a/scripts/schema/config.yaml b/scripts/schema/config.yaml new file mode 100644 index 0000000..14213b6 --- /dev/null +++ b/scripts/schema/config.yaml @@ -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 + + + + + + + + + + + + + + diff --git a/scripts/schema/policy.example.json b/scripts/schema/policy.example.json new file mode 100644 index 0000000..406939e --- /dev/null +++ b/scripts/schema/policy.example.json @@ -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-----..." +} diff --git a/scripts/schema/policy.yaml b/scripts/schema/policy.yaml new file mode 100644 index 0000000..1039a59 --- /dev/null +++ b/scripts/schema/policy.yaml @@ -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+$ } ] diff --git a/scripts/schema/topics.yaml b/scripts/schema/topics.yaml new file mode 100644 index 0000000..b554d78 --- /dev/null +++ b/scripts/schema/topics.yaml @@ -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 diff --git a/scripts/systemd/hotpocket.service b/scripts/systemd/hotpocket.service new file mode 100644 index 0000000..1c25af8 --- /dev/null +++ b/scripts/systemd/hotpocket.service @@ -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 diff --git a/scripts/systemd/hotpocket.timer b/scripts/systemd/hotpocket.timer new file mode 100644 index 0000000..c0a6a5f --- /dev/null +++ b/scripts/systemd/hotpocket.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Hotpocket Timer + +[Timer] +OnBootSec=30s +OnCalendar=*:0/10 + +[Install] +WantedBy=timers.target