Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ The format is based on [Semantic Versioning](https://semver.org/).
### Added
- GitHub Actions workflow (`.github/workflows/deploy.yml`): pull requests into `main` are checked (all Python files must compile), and merging into `main` updates and restarts the bot on the server over SSH through `tools/deploy.sh`, using a key that can only run that script. Setup: `docs/deploy.md`.

### Fixed
- Anyone logged in with a shared account (e.g. `murid`) could delete that account for everyone with `/du murid` + `y`, because the bot treated it as deleting their own account. Accounts listed in the new `SHARED_ACCOUNTS` setting (default `tamu`, `murid`, `hadirin`, `osis`, `guest`) cannot be deleted via the bot, not even by admins.
- Join/login spam detection counted everyone on a shared account behind one IP (all students on `murid` from the school network) as one person. For shared accounts it now tells people apart by nickname + IP.
- Channels created by a shared account (owned by e.g. `murid`) could be deleted or given away by anyone on that account. Non-admin shared accounts can no longer create, delete or transfer channels via the bot; admin accounts (`pemateri`, `F.OSIS`) keep their admin rights. `pemateri` and `f.osis` are in the default `SHARED_ACCOUNTS`.

### Changed
- The shipped badword list is now `badwords/default_words.txt`; `badwords/words.txt` (with the admins' `/bwa`/`/bwd` edits) is git-ignored and created from the default list on first run, so updates no longer conflict with or overwrite those edits.
- `TeamTalkBot.service` is a template for a non-root account and logs to the journal.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ All settings live in `config.json`. Highlights:
| **Lifecycle** | `CHANNEL_INACTIVITY_TIMEOUT` (`{"value": X, "unit": "days"}`), `CHANNEL_DELETION_WARNING_SECONDS` |
| **Channel Rules** | `CHANNEL_CREATION_MAX_PER_USER`, limit/block messages, `CHANNEL_CREATION_BLOCKED_USERNAMES` |
| **Registration** | `REGISTRATION_ALLOWED_USERNAMES`, per-IP limits/messages |
| **Shared accounts** | `SHARED_ACCOUNTS` – accounts many people log in with (default `tamu`, `murid`, `hadirin`, `osis`, `pemateri`, `f.osis`, `guest`): not deletable via the bot, moderation tells their users apart by nickname + IP, and unless they are admin accounts they cannot create, delete or transfer channels via the bot |
| **Abuse Throttling** | `ABUSE_LOGIN_ENABLED`, `ABUSE_JOIN_ENABLED`, `ABUSE_LOGIN_COUNT`, `ABUSE_JOIN_COUNT`, `BADWORD_ABUSE_COUNT`, `ABUSE_WINDOW_SEC`, `ABUSE_TEMP_BAN_MINUTES`, `BAN_TARGET`, `ABUSE_WHITELIST_FILE`, warning message arrays |
| **Message Anti-Spam** | `ANTISPAM_MESSAGE_ENABLED`, `ANTISPAM_MESSAGE_COUNT`, `ANTISPAM_MESSAGE_WINDOW_SEC`, `ANTISPAM_INTERCEPT_TYPES`, `ANTISPAM_IGNORE_ADMINS`, warning message arrays |
| **Badwords** | `BADWORDS_ENABLED`, `BADWORDS_INTERCEPT_TYPES` (`"PRIVATE"`, `"CHANNEL"`, `"BROADCAST"`), `BADWORDS_FILE`, `BADWORDS_IGNORE_ADMINS`, `BADWORDS_PROFILE_CHECK_ENABLED` |
Expand Down
7 changes: 7 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@
CHANNEL_CREATION_BLOCKED_MESSAGE: str = "You are not permitted to create channels."
REGISTRATION_ALLOWED_USERNAMES: List[str] = ["guest"]
REGISTRATION_NOT_ALLOWED_MESSAGE: str = "You already have a registered account and do not need to register again."
# Accounts that many people log in with. They cannot be deleted via the bot,
# moderation tells their users apart by nickname + IP instead of username, and
# (unless they are admin accounts) they cannot create or manage channels.
SHARED_ACCOUNTS: List[str] = [
"tamu", "murid", "hadirin", "osis", "pemateri", "f.osis", "guest",
]
REGISTRATION_IP_LIMIT: int = 1
REGISTRATION_IP_WINDOW_MINUTES: int = 2880
REGISTRATION_IP_LIMIT_MESSAGE: str = "Account creation failed: your IP has reached the registration limit. Please try again later."
Expand Down Expand Up @@ -173,6 +179,7 @@
"CHANNEL_CREATION_BLOCKED_MESSAGE": CHANNEL_CREATION_BLOCKED_MESSAGE,
"REGISTRATION_ALLOWED_USERNAMES": REGISTRATION_ALLOWED_USERNAMES,
"REGISTRATION_NOT_ALLOWED_MESSAGE": REGISTRATION_NOT_ALLOWED_MESSAGE,
"SHARED_ACCOUNTS": SHARED_ACCOUNTS,
"REGISTRATION_IP_LIMIT": REGISTRATION_IP_LIMIT,
"REGISTRATION_IP_WINDOW_MINUTES": REGISTRATION_IP_WINDOW_MINUTES,
"REGISTRATION_IP_LIMIT_MESSAGE": REGISTRATION_IP_LIMIT_MESSAGE,
Expand Down
1 change: 1 addition & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ <h3>Channel Creation Rules</h3>
</ul>
<h3>Registration Policy</h3>
<ul>
<li><code>SHARED_ACCOUNTS</code>: accounts that many people log in with (default <code>tamu</code>, <code>murid</code>, <code>hadirin</code>, <code>osis</code>, <code>pemateri</code>, <code>f.osis</code>, <code>guest</code>). They cannot be deleted via the bot (<code>/du</code>), even by admins, and join/login spam detection tells their users apart by nickname + IP instead of username. Non-admin shared accounts cannot create, delete or transfer channels via the bot (a channel owned by <code>murid</code> would belong to every student); admin accounts keep their admin rights.</li>
<li><code>REGISTRATION_ALLOWED_USERNAMES</code>: usernames (case-insensitive) that may request new accounts. Typically <code>["guest"]</code> to limit spammers.</li>
<li><code>REGISTRATION_NOT_ALLOWED_MESSAGE</code>: text sent when disallowed users attempt registration.</li>
<li><code>REGISTRATION_IP_LIMIT</code>, <code>REGISTRATION_IP_WINDOW_MINUTES</code>, <code>REGISTRATION_IP_LIMIT_MESSAGE</code>: per-IP quota, window length, and message when quota is exceeded.</li>
Expand Down
6 changes: 4 additions & 2 deletions help_texts.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@
),
"rc": (
"Create Channel",
"Create a new channel under the configured parent path.\n"
"Create a new channel under the configured parent path. Not available on shared "
"accounts such as murid or tamu (ask an admin).\n"
"Format:\n"
" /rc <channel_name>\n\n"
"Interactive flow:\n"
Expand Down Expand Up @@ -107,7 +108,8 @@
"- Without --force the bot asks for 'y' or 'n'.\n\n"
"Permissions:\n"
"- Admins: may delete any username.\n"
"- Non-admins: may only delete their own account.\n\n"
"- Non-admins: may only delete their own account.\n"
"- Shared accounts (e.g. murid, tamu, hadirin, osis) cannot be deleted via the bot.\n\n"
"Examples:\n"
" /du alex\n"
" /du alex --force\n",
Expand Down
48 changes: 46 additions & 2 deletions tt_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1993,6 +1993,10 @@ def _can_user_create_channel(self, requester_id: int) -> bool:
if blocklist and uname_lower in blocklist:
self.send_pm(requester_id, self._channel_creation_blocked_message())
return False
if self._is_shared_account(uname_lower) and not self._is_admin(requester_id):
# Owned by "murid", a channel would belong to every student
self.send_pm(requester_id, self._SHARED_NO_CHANNELS)
return False
max_channels = self._channel_creation_max_per_user()
if max_channels > 0:
owned = self._channel_creation_owned_count(username)
Expand Down Expand Up @@ -2408,11 +2412,27 @@ def _in_login_sync(self) -> bool:
"""True while the server replays already-online users after our login."""
return self._now() < self._login_sync_until

def _is_shared_account(self, username: str) -> bool:
values = getattr(config, "SHARED_ACCOUNTS", []) or []
return str(username or "").strip().lower() in {str(v).strip().lower() for v in values}

def _person_key(self, user: User) -> Optional[Tuple[str, str]]:
"""Identify a person across sessions by (username, IP)."""
"""Identify a person across sessions by (username, IP).

On a shared account (SHARED_ACCOUNTS, e.g. all students on "murid")
the username says nothing about who it is, so the nickname is used
as well; otherwise everyone on that account behind one IP would count
as one person.
"""
username = from_tt_char(user.szUsername).strip().lower()
ip = from_tt_char(user.szIPAddress).strip()
return (username, ip) if username and ip else None
if not username or not ip:
return None
if self._is_shared_account(username):
nickname = from_tt_char(user.szNickname).strip().lower()
if nickname:
return (f"{username}/{nickname}", ip)
return (username, ip)

def _is_reconnect(self, user: User) -> bool:
"""Whether this login looks like a reconnect after a dropped connection.
Expand Down Expand Up @@ -2792,11 +2812,19 @@ def _handle_delete_channel(self, requester_id: int, target: str, force: bool):
return
self._perform_delete_channel(requester_id, path)

_SHARED_NO_CHANNELS = (
"Shared accounts cannot create or manage channels via the bot, because everyone "
"on the account would own them. Ask an admin."
)

def _check_delete_channel_allowed(self, requester_id: int, path: str) -> bool:
# Authorization: admins can delete any channel; non-admins only their own (based on cache)
if self._is_admin(requester_id):
return True
requester_username = self._get_username(requester_id)
if self._is_shared_account(requester_username):
self.send_pm(requester_id, self._SHARED_NO_CHANNELS)
return False
owner = cache_store.get_owner(path)
if owner and owner == requester_username:
return True
Expand Down Expand Up @@ -2869,6 +2897,19 @@ def _handle_delete_user(self, requester_id: int, username: str, force: bool):
"Delete user denied; empty username (requester=%s)", requester_id
)
return
if self._is_shared_account(username):
# Deleting it would lock out everyone who logs in with it
self.send_pm(
requester_id,
f"'{username}' is a shared account (SHARED_ACCOUNTS) and cannot be deleted "
"via the bot. An admin can still remove it in the TeamTalk client.",
)
logger.warning(
"Delete of shared account refused (requester=%s, target=%s)",
requester_id,
username,
)
return
allowed_usernames = self._registration_allowed_usernames()
if allowed_usernames and username.lower() in allowed_usernames:
self.send_pm(requester_id, "This account cannot be deleted via the bot.")
Expand Down Expand Up @@ -3049,6 +3090,9 @@ def _handle_transfer_owner(self, requester_id: int, target: str, username: str):
# Reuse admin check helper for clarity
is_admin = self._is_admin(requester_id)
requester_username = self._get_username(requester_id)
if not is_admin and self._is_shared_account(requester_username):
self.send_pm(requester_id, self._SHARED_NO_CHANNELS)
return
if not is_admin:
if not current or current != requester_username:
self.send_pm(
Expand Down
Loading