Add trust flag for repeaters and make default config optional

Features:
- Add trust flag to RepeaterConfig (default: false)
  - Trusted repeaters can use any TG dynamically via OPTIONS
  - Config TGs become defaults instead of restrictions
  - Use for core network, testing, or administrative repeaters
- Make default repeater config optional
  - Omitting default rejects non-matching repeaters
  - Provides better security by requiring explicit config

Code improvements:
- Remove deprecated _is_talkgroup_allowed() method (never called)
- Remove defensive exception masking from _load_repeater_tg_config()
- Implement fail-fast design - bugs crash immediately with clear traceback
- Authentication at line 1120 is single point of truth for config validation

Documentation:
- Update configuration.md with trust flag examples and security notes
- Explain optional default behavior
- Update config_sample.json with trusted repeater example
This commit is contained in:
cort committed 2025-10-10 07:30:04 -05:00
1 parent a91d676b98
commit 7da5c4b94d
4 files changed
+127 -120

No files matched your search

+8 -55
View File
@@ -31,21 +31,6 @@
"unix_socket": "/tmp/hblink4.sock",
"buffer_size": 65536
},
"global_alternate_example": {
"comment": "Alternative configuration showing longer hang time for slower operators",
"max_missed": 5,
"use_ipv6": false,
"bind_ip": "0.0.0.0",
"bind_port": 62031,
"logging": {
"file": "logs/hblink.log",
"console_level": "INFO",
"file_level": "DEBUG",
"retention_days": 30
},
"stream_timeout": 2.0,
"stream_hang_time": 20.0
},
"blacklist": {
"patterns": [
{
@@ -85,29 +70,18 @@
"repeater_configurations": {
"patterns": [
{
"name": "KS-DMR Network",
"description": "All repeaters in the KS-DMR network",
"name": "Core Network Repeaters (Trusted)",
"description": "Trusted repeaters managed by core team - can use any TG",
"match": {
"id_ranges": [[312000, 312099]]
"ids": [312000, 312001]
},
"config": {
"passphrase": "ks-dmr-network-key",
"passphrase": "core-network-key",
"trust": true,
"slot1_talkgroups": [8, 9],
"slot2_talkgroups": [3120, 3121, 3122]
}
},
{
"name": "WA0EDA Repeaters",
"description": "All WA0EDA club repeaters",
"match": {
"callsigns": ["WA0EDA*"]
},
"config": {
"passphrase": "wa0eda-network-key",
"slot1_talkgroups": [8],
"slot2_talkgroups": [31201, 31202]
}
},
{
"name": "Regional Network Repeaters",
"description": "Example of multiple ranges in one pattern - regions 310, 311, 312",
@@ -116,6 +90,7 @@
},
"config": {
"passphrase": "regional-network-key",
"trust": false,
"slot1_talkgroups": [1, 2, 3, 8],
"slot2_talkgroups": [3100, 3110, 3120]
}
@@ -135,30 +110,8 @@
],
"default": {
"passphrase": "passw0rd",
"slot1_talkgroups": [1],
"slot2_talkgroups": [2]
}
},
"talkgroups": {
"8": {
"name": "Local",
"bridge": false
},
"9": {
"name": "Alternate KS-DMR",
"bridge": true
},
"3120": {
"name": "KS Statewide",
"bridge": true
},
"3122": {
"name": "KS-DMR Tech",
"bridge": false
},
"31202": {
"name": "WA0EDA Event",
"bridge": false
"slot1_talkgroups": [8],
"slot2_talkgroups": [8]
}
}
}
+43 -1
View File
@@ -323,7 +323,7 @@ Multiple match types in a single pattern are combined with OR logic (any match t
## Repeater Configurations
The `repeater_configurations` section defines patterns for matching repeaters and their configurations. It includes a default configuration and specific patterns.
The `repeater_configurations` section defines patterns for matching repeaters and their configurations. It includes an optional default configuration and specific patterns.
```json
{
@@ -338,6 +338,8 @@ The `repeater_configurations` section defines patterns for matching repeaters an
}
```
**Note:** The `default` configuration is **optional**. If omitted, repeaters that don't match any pattern will be rejected during authentication. This provides better security by requiring explicit configuration for all connecting repeaters.
### Pattern Structure
Each pattern defines a match rule and associated configuration:
@@ -438,6 +440,7 @@ Multiple match types in a single pattern are combined with OR logic (any match t
| `passphrase` | string | Authentication key for the repeater (required) |
| `slot1_talkgroups` | array | List of allowed talkgroup IDs for timeslot 1 (bidirectional) |
| `slot2_talkgroups` | array | List of allowed talkgroup IDs for timeslot 2 (bidirectional) |
| `trust` | boolean | If true, repeater can use any TG (config TGs become defaults) |
**Symmetric Routing:**
The same talkgroup lists control BOTH directions:
@@ -481,6 +484,45 @@ The same talkgroup lists control BOTH directions:
}
```
### Trusted Repeaters
The `trust` flag allows designated repeaters to bypass talkgroup restrictions. This is useful for core network repeaters managed by trusted operators.
**Normal Behavior (trust: false or not set):**
- Repeater requests TGs via OPTIONS packet
- Server uses **intersection** of (requested TGs) ∩ (configured TGs)
- Repeater limited to only configured TGs
**Trusted Behavior (trust: true):**
- Repeater requests TGs via OPTIONS packet
- Server uses requested TGs **as-is** (no intersection)
- If no TGs requested, configured TGs used as **defaults**
- Trusted repeaters can dynamically access any TG without server reconfiguration
**Example:**
```json
{
"name": "Core Network Repeaters",
"description": "Trusted repeaters managed by core team",
"match": {
"ids": [312000, 312001]
},
"config": {
"passphrase": "core-secret-key",
"trust": true,
"slot1_talkgroups": [8, 9], // Defaults if OPTIONS not sent
"slot2_talkgroups": [3120, 3121] // Defaults if OPTIONS not sent
}
}
```
**Use Cases:**
- **Core network repeaters** - Full access for network management
- **Testing/development** - Trusted test repeaters can access any TG
- **Administrative repeaters** - Network monitoring and troubleshooting
**Security Note:** Only assign `trust: true` to repeaters under your direct control. Trusted repeaters have unrestricted access to all talkgroups on the network.
### Pattern Matching Priority
Patterns are evaluated in the order they appear in the configuration file. The first pattern that matches is used. Within each pattern, all match types (IDs, ID ranges, callsigns) are checked with OR logic.
+11 -7
View File
@@ -65,6 +65,7 @@ class RepeaterConfig:
# None = allow all TGs, [] = deny all TGs, [1,2,3] = specific TGs
slot1_talkgroups: Optional[List[int]] = None
slot2_talkgroups: Optional[List[int]] = None
trust: bool = False # If True, use requested TGs as-is (config = defaults only)
@dataclass
class PatternMatch:
@@ -98,11 +99,12 @@ class RepeaterMatcher:
self.blacklist = self._parse_blacklist(config.get('blacklist', {"patterns": []}))
repeater_config = config.get('repeater_configurations', config.get('repeaters', {}))
self.patterns = self._parse_patterns(repeater_config.get('patterns', []))
self.default_config = RepeaterConfig(**repeater_config.get('default', {
"passphrase": "passw0rd",
"slot1_talkgroups": [8],
"slot2_talkgroups": [8]
}))
# Make default config optional - only use if explicitly provided
if 'default' in repeater_config:
self.default_config = RepeaterConfig(**repeater_config['default'])
else:
self.default_config = None
def _parse_blacklist(self, blacklist_config: Dict[str, Any]) -> List[BlacklistMatch]:
"""Parse blacklist patterns from config - supports multiple match types per pattern"""
@@ -174,7 +176,7 @@ class RepeaterMatcher:
if self._match_pattern(radio_id, callsign, pattern):
raise BlacklistError(pattern.name, pattern.reason)
def get_repeater_config(self, radio_id: int, callsign: Optional[str] = None) -> RepeaterConfig:
def get_repeater_config(self, radio_id: int, callsign: Optional[str] = None) -> Optional[RepeaterConfig]:
"""
Get the configuration for a connecting repeater based on its ID and/or callsign.
First checks blacklist, then checks patterns in order (first match wins).
@@ -182,6 +184,8 @@ class RepeaterMatcher:
Patterns can now contain multiple match types (ids, id_ranges, callsigns) for flexibility.
Returns None if no patterns match and no default configuration is defined.
Raises:
BlacklistError: If the repeater matches any blacklist pattern
"""
@@ -193,7 +197,7 @@ class RepeaterMatcher:
if self._match_pattern(radio_id, callsign, pattern):
return pattern.config
# If no patterns match, return default configuration
# If no patterns match, return default configuration (or None if not defined)
return self.default_config
def get_pattern_for_repeater(self, radio_id: int, callsign: Optional[str] = None) -> Optional[PatternMatch]:
+65 -57
View File
@@ -292,21 +292,20 @@ class HBProtocol(DatagramProtocol):
"""
Load and cache TG configuration for a repeater.
Converts config lists to sets for O(1) routing lookups.
Note: Config must exist - repeater was already authenticated with this config.
If this fails, it indicates a bug in authentication logic that must be fixed.
"""
try:
repeater_config = self._matcher.get_repeater_config(
self._rid_to_int(repeater_id),
repeater.get_callsign_str()
)
# Convert config to internal representation:
# None stays None (allow all), lists become sets
repeater.slot1_talkgroups = set(repeater_config.slot1_talkgroups) if repeater_config.slot1_talkgroups is not None else None
repeater.slot2_talkgroups = set(repeater_config.slot2_talkgroups) if repeater_config.slot2_talkgroups is not None else None
except Exception as e:
LOGGER.warning(f'Could not load TG config for repeater {self._rid_to_int(repeater_id)}: {e}')
# No config = None (allow all for backward compatibility)
repeater.slot1_talkgroups = None
repeater.slot2_talkgroups = None
repeater_config = self._matcher.get_repeater_config(
self._rid_to_int(repeater_id),
repeater.get_callsign_str()
)
# Convert config to internal representation:
# None stays None (allow all), lists become sets
repeater.slot1_talkgroups = set(repeater_config.slot1_talkgroups) if repeater_config.slot1_talkgroups is not None else None
repeater.slot2_talkgroups = set(repeater_config.slot2_talkgroups) if repeater_config.slot2_talkgroups is not None else None
# ========== END HELPER METHODS ==========
@@ -784,25 +783,6 @@ class HBProtocol(DatagramProtocol):
return repeater
def _is_talkgroup_allowed(self, repeater: RepeaterState, dst_id: bytes) -> bool:
"""Check if a talkgroup is allowed for this repeater based on its configuration"""
try:
# Get the repeater's configuration
repeater_config = self._matcher.get_repeater_config(
int.from_bytes(repeater.repeater_id, 'big'),
repeater.callsign.decode().strip() if repeater.callsign else None
)
# Convert dst_id to int for comparison
talkgroup = int.from_bytes(dst_id, 'big')
# Check if this talkgroup is in the allowed list
return talkgroup in repeater_config.talkgroups
except Exception as e:
LOGGER.error(f'Error checking talkgroup permissions: {e}')
return False
def _handle_stream_start(self, repeater: RepeaterState, rf_src: bytes, dst_id: bytes,
slot: int, stream_id: bytes, call_type_bit: int = 1) -> bool:
"""
@@ -1103,6 +1083,13 @@ class HBProtocol(DatagramProtocol):
repeater.get_callsign_str()
)
# If no matching configuration found, reject the connection
if repeater_config is None:
LOGGER.warning(f'Repeater {self._rid_to_int(repeater_id)} does not match any configured patterns and no default is set')
self._send_nak(repeater_id, addr, reason="No matching configuration")
self._remove_repeater(repeater_id, "no_config_match")
return
# Validate the hash
salt_bytes = repeater.salt.to_bytes(4, 'big')
calc_hash = bytes.fromhex(sha256(b''.join([salt_bytes, repeater_config.passphrase.encode()])).hexdigest())
@@ -1261,6 +1248,7 @@ class HBProtocol(DatagramProtocol):
self._rid_to_int(repeater_id),
repeater.callsign.decode().strip() if repeater.callsign else None
)
# Convert config to sets, handling None (allow all) properly
# None = allow all TGs, [] = deny all, [1,2,3] = specific TGs
config_ts1 = set(repeater_config.slot1_talkgroups) if repeater_config.slot1_talkgroups is not None else None
@@ -1284,31 +1272,51 @@ class HBProtocol(DatagramProtocol):
requested_ts2 = {int(tg.strip()) for tg in value.split(',')
if tg.strip().isdigit()}
# Filter: only accept TGs that are in config (intersection)
# If config is None (allow all), any RPTO request is valid (subset of "all")
# If config is a set, only grant intersection (RPTO can restrict, not expand)
if config_ts1 is None:
# Config allows all TGs, so grant whatever repeater requested
final_ts1 = requested_ts1 if requested_ts1 else None # None = keep "allow all"
else:
# Config has specific TGs, filter RPTO to only those in config
final_ts1 = requested_ts1 & config_ts1 if requested_ts1 else config_ts1
if config_ts2 is None:
final_ts2 = requested_ts2 if requested_ts2 else None
else:
final_ts2 = requested_ts2 & config_ts2 if requested_ts2 else config_ts2
# Log any requested TGs that were rejected (only when config has restrictions)
if config_ts1 is not None:
rejected_ts1 = requested_ts1 - config_ts1
else:
rejected_ts1 = set() # No rejections when config allows all
if config_ts2 is not None:
rejected_ts2 = requested_ts2 - config_ts2
else:
# Check if this repeater is trusted
if repeater_config.trust:
# Trusted repeater: use requested TGs as-is, config TGs become defaults
final_ts1 = requested_ts1 if requested_ts1 else (config_ts1 if config_ts1 else None)
final_ts2 = requested_ts2 if requested_ts2 else (config_ts2 if config_ts2 else None)
# Log trust usage - show any TGs beyond config (informational, not warning)
if config_ts1 is not None and requested_ts1:
extra_ts1 = requested_ts1 - config_ts1
if extra_ts1:
LOGGER.info(f'🔓 Trusted repeater {self._rid_to_int(repeater_id)} using additional TS1 TGs: {sorted(extra_ts1)}')
if config_ts2 is not None and requested_ts2:
extra_ts2 = requested_ts2 - config_ts2
if extra_ts2:
LOGGER.info(f'🔓 Trusted repeater {self._rid_to_int(repeater_id)} using additional TS2 TGs: {sorted(extra_ts2)}')
rejected_ts1 = set()
rejected_ts2 = set()
else:
# Standard behavior: intersection of requested and config
# If config is None (allow all), any RPTO request is valid (subset of "all")
# If config is a set, only grant intersection (RPTO can restrict, not expand)
if config_ts1 is None:
# Config allows all TGs, so grant whatever repeater requested
final_ts1 = requested_ts1 if requested_ts1 else None # None = keep "allow all"
else:
# Config has specific TGs, filter RPTO to only those in config
final_ts1 = requested_ts1 & config_ts1 if requested_ts1 else config_ts1
if config_ts2 is None:
final_ts2 = requested_ts2 if requested_ts2 else None
else:
final_ts2 = requested_ts2 & config_ts2 if requested_ts2 else config_ts2
# Log any requested TGs that were rejected (only when config has restrictions)
if config_ts1 is not None:
rejected_ts1 = requested_ts1 - config_ts1
else:
rejected_ts1 = set() # No rejections when config allows all
if config_ts2 is not None:
rejected_ts2 = requested_ts2 - config_ts2
else:
rejected_ts2 = set()
if rejected_ts1:
LOGGER.warning(f'⚠️ TS1 TG(s) {sorted(rejected_ts1)} requested by repeater {self._rid_to_int(repeater_id)} not allowed by config')
if rejected_ts2: