mirror of
https://github.com/n0mjs710/HBlink4.git
synced 2026-11-08 04:19:26 +00:00
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:
1 parent
a91d676b98
commit
7da5c4b94d
4 files changed
+127
-120
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user