450 lines
14 KiB
Bash
Executable File
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
|