#!/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 . -c <<< "$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!" [ -n "$DRY_RUN" ] && 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 () { # arrayIndexList: 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: $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: # TODO: While this uses action's tags to match against instead of the rules, it doesn't report the # alias which is resulting in the match. Needs more looking into # jq --argjson aliases "$TOPICS" '[foreach ($aliases | to_entries[]) as $alias (.; if any($alias.value[] == .) then . + [$alias.key] else . end)] | last' --< /dev/stdin jq --argjson topics "$TOPICS" \ '[foreach ($topics | to_entries | reverse[]) as $topic (.; if index($topic.key) then . + $topic.value 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 knows 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")" || 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