From 0f3b5a6bac70b3b6d12c49a64a2d6b1c76ce7aad Mon Sep 17 00:00:00 2001 From: Jon Date: Sun, 20 Feb 2022 14:43:11 +0000 Subject: [PATCH] Initial public commit --- .gitignore | 6 + config.d/1_config.yaml | 9 + config.d/2_action_high_risk_porn.yaml | 18 ++ config.d/3_action_nsfw.yaml | 6 + docs/debian_11.md | 145 +++++++++ docs/hotpocket.png | Bin 0 -> 5762 bytes docs/tags.md | 28 ++ hotpocket.sh | 438 ++++++++++++++++++++++++++ readme.md | 45 +++ scripts/polmake.sh | 58 ++++ scripts/polsign.sh | 24 ++ scripts/schema/config.yaml | 109 +++++++ scripts/schema/policy.example.json | 15 + scripts/schema/policy.yaml | 28 ++ scripts/schema/topics.yaml | 22 ++ scripts/systemd/hotpocket.service | 9 + scripts/systemd/hotpocket.timer | 9 + 17 files changed, 969 insertions(+) create mode 100644 .gitignore create mode 100644 config.d/1_config.yaml create mode 100644 config.d/2_action_high_risk_porn.yaml create mode 100644 config.d/3_action_nsfw.yaml create mode 100644 docs/debian_11.md create mode 100644 docs/hotpocket.png create mode 100644 docs/tags.md create mode 100755 hotpocket.sh create mode 100644 readme.md create mode 100755 scripts/polmake.sh create mode 100755 scripts/polsign.sh create mode 100644 scripts/schema/config.yaml create mode 100644 scripts/schema/policy.example.json create mode 100644 scripts/schema/policy.yaml create mode 100644 scripts/schema/topics.yaml create mode 100644 scripts/systemd/hotpocket.service create mode 100644 scripts/systemd/hotpocket.timer 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 0000000000000000000000000000000000000000..86ac97e47ff6dda76f3d8d50f1d50b7bdc6d8de1 GIT binary patch literal 5762 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 | 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