diff --git a/packetserver/common/util.py b/packetserver/common/util.py index 8868150..344d7cf 100644 --- a/packetserver/common/util.py +++ b/packetserver/common/util.py @@ -10,6 +10,14 @@ import string from persistent.mapping import PersistentMapping from persistent.list import PersistentList +# Pre-compiled regex for performance +_AX25_CALLSIGN_REGEX = re.compile( + r'^[A-Z0-9]{1,6}(-[0-9]{1,2})?$' +) + +# Regex for base callsign only (no SSID) +_BASE_CALLSIGN_REGEX = re.compile(r'^[A-Z][A-Z0-9]{0,5}$') + def email_valid(email: str) -> bool: """Taken from https://www.geeksforgeeks.org/check-if-email-address-valid-or-not-in-python/""" regex = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' @@ -172,4 +180,70 @@ def convert_from_persistent(data): elif isinstance(data, PersistentList): return [convert_from_persistent(item) for item in data] else: - return data \ No newline at end of file + return data + +def is_valid_ax25_callsign(callsign: str) -> bool: + """ + Validate an AX.25 callsign (with optional SSID). + + Rules: + - Base: 1-6 uppercase alphanumeric, must start with letter (A-Z) + - Optional: single '-' followed by 1-2 digits (0-15) + - No spaces, lowercase, or other characters + + Examples: + W1AW → True + W1AW-1 → True + W1AW-15 → True + 1A2BCD → False (starts with digit) + w1aw → False (lowercase) + W1AW- → False (no SSID digits) + W1AW--1 → False (double dash) + W1AW-16 → False (SSID >15) + """ + if not callsign: + return False + callsign = callsign.strip().upper() + if not _AX25_CALLSIGN_REGEX.match(callsign): + return False + + # Extra check: base must start with letter (not digit) + base = callsign.split('-')[0] + if not base[0].isalpha(): + return False + + # If SSID present, ensure 0-15 + if '-' in callsign: + ssid_str = callsign.split('-')[1] + if int(ssid_str) > 15: + return False + + return True + +def is_valid_base_ax25_callsign(base_call: str) -> bool: + """ + Validate an AX.25 **base** callsign (without SSID). + + Rules: + - 1–6 characters total + - Must start with an uppercase letter (A–Z) + - Remaining characters: uppercase letters A–Z or digits 0–9 + - No hyphen, no SSID, no other characters + + Examples: + W1AW → True + M0XYZ → True + K9ABC → True (6 chars) + 1ABC → False (starts with digit) + W1AW- → False (has hyphen) + W1AW-1 → False (has SSID) + w1aw → False (lowercase) + A → True (single letter) + ABC1234 → False (7 chars) + + Use this when you specifically need the base portion (e.g., before adding SSID). + """ + if not base_call: + return False + base_call = base_call.strip().upper() + return bool(_BASE_CALLSIGN_REGEX.match(base_call)) \ No newline at end of file diff --git a/packetserver/http/auth.py b/packetserver/http/auth.py index b8bb07e..30c99f1 100644 --- a/packetserver/http/auth.py +++ b/packetserver/http/auth.py @@ -3,6 +3,8 @@ from persistent import Persistent from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError import time +from persistent.mapping import PersistentMapping +from persistent.list import PersistentList ph = PasswordHasher() @@ -64,7 +66,7 @@ class HttpUser(Persistent): Only allows enabling if the username is a valid AX.25 callsign. """ import transaction - from packetserver.utils import is_valid_ax25_callsign # assuming you have this helper + from packetserver.common.util import is_valid_ax25_callsign # assuming you have this helper root = transaction.get().db().root() config = root.setdefault('config', PersistentMapping()) diff --git a/packetserver/runners/http_user_manager.py b/packetserver/runners/http_user_manager.py index 3d56154..b84e50f 100644 --- a/packetserver/runners/http_user_manager.py +++ b/packetserver/runners/http_user_manager.py @@ -13,6 +13,7 @@ import argparse import sys import time from getpass import getpass +import ax25 import ZODB.FileStorage import ZODB.DB @@ -102,7 +103,14 @@ def main(): upper_callsign = lambda c: c.upper() if args.command == "add": + from packetserver.common.util import is_valid_ax25_callsign callsign = upper_callsign(args.callsign) + if is_valid_ax25_callsign(callsign): + base = ax25.Address(callsign).call.upper() + if base != callsign: + print(f"Error: Trying to add valid callsign + ssid. Remove - and add again.") + sys.exit(1) + if callsign in users_mapping: print(f"Error: HTTP user {callsign} already exists") sys.exit(1) @@ -121,7 +129,8 @@ def main(): main_users = root.setdefault('users', PersistentMapping()) if callsign not in main_users: - User.write_new(main_users, args.callsign) + new_user = User(callsign) + new_user.write_new(root) print(f" → Also created regular BBS user {callsign} (with UUID)") else: print(f" → Regular BBS user {callsign} already exists")