This repository has been archived on 2022-03-07. You can view files and clone it, but cannot push or open issues or pull requests.
hotpocket/hotpocket.sh

450 lines
14 KiB
Bash
Executable File

#!/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<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: $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