mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Add security hardening and bias-t support
Security improvements: - Add interface name validation to prevent command injection - Fix XSS vulnerability in pager message display - Add security headers (X-Content-Type-Options, X-Frame-Options, etc.) - Disable Werkzeug debug PIN - Add security documentation Features: - Add bias-t power support for SDR dongles across all modes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,7 @@ Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
||||
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
||||
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||
- [Security](docs/SECURITY.md) - Network security and best practices
|
||||
|
||||
---
|
||||
|
||||
|
||||
24
app.py
24
app.py
@@ -45,6 +45,30 @@ _app_start_time = _time.time()
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
|
||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||
|
||||
|
||||
# ============================================
|
||||
# SECURITY HEADERS
|
||||
# ============================================
|
||||
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""Add security headers to all responses."""
|
||||
# Prevent MIME type sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Prevent clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# Enable XSS filter
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Referrer policy
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Permissions policy (disable unnecessary features)
|
||||
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
|
||||
return response
|
||||
|
||||
|
||||
# ============================================
|
||||
# GLOBAL PROCESS MANAGEMENT
|
||||
# ============================================
|
||||
|
||||
89
docs/SECURITY.md
Normal file
89
docs/SECURITY.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Security Considerations
|
||||
|
||||
INTERCEPT is designed as a **local signal intelligence tool** for personal use on trusted networks. This document outlines security considerations and best practices.
|
||||
|
||||
## Network Binding
|
||||
|
||||
By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any network interface. This is convenient for accessing the web UI from other devices on your local network, but has security implications:
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. **Firewall Rules**: If you don't need remote access, configure your firewall to block external access to port 5050:
|
||||
```bash
|
||||
# Linux (iptables)
|
||||
sudo iptables -A INPUT -p tcp --dport 5050 -s 127.0.0.1 -j ACCEPT
|
||||
sudo iptables -A INPUT -p tcp --dport 5050 -j DROP
|
||||
|
||||
# macOS (pf)
|
||||
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
2. **Bind to Localhost**: For local-only access, set the host environment variable:
|
||||
```bash
|
||||
export INTERCEPT_HOST=127.0.0.1
|
||||
python intercept.py
|
||||
```
|
||||
|
||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||
|
||||
## Authentication
|
||||
|
||||
INTERCEPT does **not** include authentication. This is by design for ease of use as a personal tool. If you need to expose INTERCEPT to untrusted networks:
|
||||
|
||||
1. Use a reverse proxy (nginx, Caddy) with authentication
|
||||
2. Use a VPN to access your home network
|
||||
3. Use SSH port forwarding: `ssh -L 5050:localhost:5050 your-server`
|
||||
|
||||
## Security Headers
|
||||
|
||||
INTERCEPT includes the following security headers on all responses:
|
||||
|
||||
| Header | Value | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing |
|
||||
| `X-Frame-Options` | `SAMEORIGIN` | Prevent clickjacking |
|
||||
| `X-XSS-Protection` | `1; mode=block` | Enable browser XSS filter |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer information |
|
||||
| `Permissions-Policy` | `geolocation=(self), microphone=()` | Restrict browser features |
|
||||
|
||||
## Input Validation
|
||||
|
||||
All user inputs are validated before use:
|
||||
|
||||
- **Network interface names**: Validated against strict regex pattern
|
||||
- **Bluetooth interface names**: Must match `hciX` format
|
||||
- **MAC addresses**: Validated format
|
||||
- **Frequencies**: Validated range and format
|
||||
- **File paths**: Protected against directory traversal
|
||||
- **HTML output**: All user-provided content is escaped
|
||||
|
||||
## Subprocess Execution
|
||||
|
||||
INTERCEPT executes external tools (rtl_fm, airodump-ng, etc.) via subprocess. Security measures:
|
||||
|
||||
- **No shell execution**: All subprocess calls use list arguments, not shell strings
|
||||
- **Input validation**: All user-provided arguments are validated before use
|
||||
- **Process isolation**: Each tool runs in its own process with limited permissions
|
||||
|
||||
## Debug Mode
|
||||
|
||||
Debug mode is **disabled by default**. If enabled via `INTERCEPT_DEBUG=true`:
|
||||
|
||||
- The Werkzeug debugger PIN is disabled (not needed for local tool)
|
||||
- Additional logging is enabled
|
||||
- Stack traces are shown on errors
|
||||
|
||||
**Never run in debug mode on untrusted networks.**
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
If you discover a security vulnerability, please report it by:
|
||||
|
||||
1. Opening a GitHub issue (for non-sensitive issues)
|
||||
2. Emailing the maintainer directly (for sensitive issues)
|
||||
|
||||
Please include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
@@ -381,9 +381,11 @@ def start_adsb():
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
|
||||
# Build ADS-B decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_adsb_command(
|
||||
device=sdr_device,
|
||||
gain=float(gain)
|
||||
gain=float(gain),
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
# For RTL-SDR, ensure we use the found dump1090 path
|
||||
|
||||
@@ -21,6 +21,7 @@ import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||
from utils.constants import (
|
||||
@@ -304,9 +305,14 @@ def start_bt_scan():
|
||||
|
||||
data = request.json
|
||||
scan_mode = data.get('mode', 'hcitool')
|
||||
interface = data.get('interface', 'hci0')
|
||||
scan_ble = data.get('scan_ble', True)
|
||||
|
||||
# Validate Bluetooth interface name
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
app_module.bt_interface = interface
|
||||
app_module.bt_devices = {}
|
||||
|
||||
@@ -388,7 +394,12 @@ def stop_bt_scan():
|
||||
def reset_bt_adapter():
|
||||
"""Reset Bluetooth adapter."""
|
||||
data = request.json
|
||||
interface = data.get('interface', 'hci0')
|
||||
|
||||
# Validate Bluetooth interface name
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
|
||||
@@ -54,6 +54,7 @@ scanner_config = {
|
||||
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
||||
'device': 0,
|
||||
'gain': 40,
|
||||
'bias_t': False, # Bias-T power for external LNA
|
||||
}
|
||||
|
||||
# Activity log
|
||||
@@ -177,6 +178,9 @@ def scanner_loop():
|
||||
'-g', str(scanner_config['gain']),
|
||||
'-d', str(scanner_config['device']),
|
||||
]
|
||||
# Add bias-t flag if enabled (for external LNA power)
|
||||
if scanner_config.get('bias_t', False):
|
||||
rtl_cmd.append('-T')
|
||||
|
||||
try:
|
||||
# Start rtl_fm
|
||||
@@ -365,6 +369,9 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
'-d', str(scanner_config['device']),
|
||||
'-l', str(scanner_config['squelch']),
|
||||
]
|
||||
# Add bias-t flag if enabled (for external LNA power)
|
||||
if scanner_config.get('bias_t', False):
|
||||
rtl_cmd.append('-T')
|
||||
|
||||
encoder_cmd = [
|
||||
ffmpeg_path,
|
||||
@@ -497,6 +504,7 @@ def start_scanner() -> Response:
|
||||
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
||||
scanner_config['device'] = int(data.get('device', 0))
|
||||
scanner_config['gain'] = int(data.get('gain', 40))
|
||||
scanner_config['bias_t'] = bool(data.get('bias_t', False))
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
|
||||
@@ -233,6 +233,7 @@ def start_decoding() -> Response:
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build FM demodulation command
|
||||
bias_t = data.get('bias_t', False)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
@@ -240,7 +241,8 @@ def start_decoding() -> Response:
|
||||
gain=float(gain) if gain and gain != '0' else None,
|
||||
ppm=int(ppm) if ppm and ppm != '0' else None,
|
||||
modulation='fm',
|
||||
squelch=squelch if squelch and squelch != 0 else None
|
||||
squelch=squelch if squelch and squelch != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||
|
||||
@@ -114,11 +114,13 @@ def start_sensor() -> Response:
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
|
||||
@@ -19,7 +19,7 @@ import app as app_module
|
||||
from utils.dependencies import check_tool, get_tool_path
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse
|
||||
from data.oui import get_manufacturer
|
||||
from utils.constants import (
|
||||
@@ -303,11 +303,13 @@ def get_wifi_interfaces():
|
||||
def toggle_monitor_mode():
|
||||
"""Enable or disable monitor mode on an interface."""
|
||||
data = request.json
|
||||
interface = data.get('interface')
|
||||
action = data.get('action', 'start')
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No interface specified'})
|
||||
# Validate interface name to prevent command injection
|
||||
try:
|
||||
interface = validate_network_interface(data.get('interface'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
if action == 'start':
|
||||
if check_tool('airmon-ng'):
|
||||
@@ -458,10 +460,19 @@ def start_wifi_scan():
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
|
||||
data = request.json
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
channel = data.get('channel')
|
||||
band = data.get('band', 'abg')
|
||||
|
||||
# Use provided interface or fall back to stored monitor interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface available.'})
|
||||
|
||||
@@ -557,7 +568,16 @@ def send_deauth():
|
||||
target_bssid = data.get('bssid')
|
||||
target_client = data.get('client', 'FF:FF:FF:FF:FF:FF')
|
||||
count = data.get('count', 5)
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'Target BSSID required'})
|
||||
@@ -612,7 +632,16 @@ def capture_handshake():
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid or not channel:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
|
||||
@@ -701,7 +730,16 @@ def capture_pmkid():
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID required'})
|
||||
|
||||
@@ -330,6 +330,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global SDR Power Settings -->
|
||||
<div class="section" style="padding: 8px; margin-bottom: 10px; background: rgba(0, 212, 255, 0.05); border-radius: 4px;">
|
||||
<div class="checkbox-group" style="margin: 0;">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="biasT" onchange="saveBiasTSetting()">
|
||||
<span style="color: var(--accent-cyan);">Bias-T Power</span>
|
||||
<span style="font-size: 10px; color: #666;">(LNA/Preamp)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toolStatusPager" class="info-text tool-status-section" style="display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
|
||||
<span>rtl_fm:</span><span class="tool-status {{ 'ok' if tools.rtl_fm else 'missing' }}">{{ 'OK' if tools.rtl_fm else 'Missing' }}</span>
|
||||
<span>multimon-ng:</span><span class="tool-status {{ 'ok' if tools.multimon else 'missing' }}">{{ 'OK' if tools.multimon else 'Missing' }}</span>
|
||||
@@ -2192,6 +2203,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Load bias-T setting from localStorage
|
||||
loadBiasTSetting();
|
||||
|
||||
// Initialize observer location input fields from saved location
|
||||
const adsbLatInput = document.getElementById('adsbObsLat');
|
||||
const adsbLonInput = document.getElementById('adsbObsLon');
|
||||
@@ -2373,7 +2387,8 @@
|
||||
gain: gain,
|
||||
ppm: ppm,
|
||||
device: device,
|
||||
sdr_type: getSelectedSDRType()
|
||||
sdr_type: getSelectedSDRType(),
|
||||
bias_t: getBiasTEnabled()
|
||||
};
|
||||
|
||||
// Add rtl_tcp params if using remote SDR
|
||||
@@ -2950,6 +2965,24 @@
|
||||
return document.getElementById('sdrTypeSelect').value;
|
||||
}
|
||||
|
||||
// Bias-T power setting
|
||||
function saveBiasTSetting() {
|
||||
const enabled = document.getElementById('biasT')?.checked || false;
|
||||
localStorage.setItem('biasTEnabled', enabled);
|
||||
}
|
||||
|
||||
function getBiasTEnabled() {
|
||||
return document.getElementById('biasT')?.checked || false;
|
||||
}
|
||||
|
||||
function loadBiasTSetting() {
|
||||
const saved = localStorage.getItem('biasTEnabled');
|
||||
if (saved === 'true') {
|
||||
const checkbox = document.getElementById('biasT');
|
||||
if (checkbox) checkbox.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRemoteSDR() {
|
||||
const useRemote = document.getElementById('useRemoteSDR').checked;
|
||||
const configDiv = document.getElementById('remoteSDRConfig');
|
||||
@@ -3017,7 +3050,8 @@
|
||||
ppm: ppm,
|
||||
device: device,
|
||||
sdr_type: getSelectedSDRType(),
|
||||
protocols: protocols
|
||||
protocols: protocols,
|
||||
bias_t: getBiasTEnabled()
|
||||
};
|
||||
|
||||
// Add rtl_tcp params if using remote SDR
|
||||
@@ -3206,10 +3240,10 @@
|
||||
msgEl.className = 'message ' + protoClass;
|
||||
msgEl.innerHTML = `
|
||||
<div class="header">
|
||||
<span class="protocol">${msg.protocol}</span>
|
||||
<span class="msg-time" data-timestamp="${msg.timestamp}" title="${msg.timestamp}">${relativeTime}</span>
|
||||
<span class="protocol">${escapeHtml(msg.protocol)}</span>
|
||||
<span class="msg-time" data-timestamp="${escapeAttr(msg.timestamp)}" title="${escapeAttr(msg.timestamp)}">${escapeHtml(relativeTime)}</span>
|
||||
</div>
|
||||
<div class="address">Address: ${msg.address}${msg.function ? ' | Func: ' + msg.function : ''}</div>
|
||||
<div class="address">Address: ${escapeHtml(msg.address)}${msg.function ? ' | Func: ' + escapeHtml(msg.function) : ''}</div>
|
||||
<div class="content ${isNumeric ? 'numeric' : ''}">${escapeHtml(msg.message)}</div>
|
||||
`;
|
||||
|
||||
@@ -6802,7 +6836,7 @@
|
||||
fetch('/adsb/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gain, device, sdr_type })
|
||||
body: JSON.stringify({ gain, device, sdr_type, bias_t: getBiasTEnabled() })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -8135,7 +8169,8 @@
|
||||
modulation: modulation,
|
||||
squelch: squelch,
|
||||
dwell_time: dwell,
|
||||
device: device
|
||||
device: device,
|
||||
bias_t: getBiasTEnabled()
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
|
||||
@@ -195,3 +195,64 @@ def sanitize_device_name(name: str | None) -> str:
|
||||
return ''
|
||||
# Escape HTML and limit length
|
||||
return escape_html(str(name)[:64])
|
||||
|
||||
|
||||
def validate_network_interface(name: Any) -> str:
|
||||
"""
|
||||
Validate network interface name to prevent command injection.
|
||||
|
||||
Interface names must:
|
||||
- Start with a letter
|
||||
- Contain only alphanumeric, underscore, or hyphen
|
||||
- Be 1-15 characters long (Linux IFNAMSIZ limit)
|
||||
|
||||
Args:
|
||||
name: Interface name to validate
|
||||
|
||||
Returns:
|
||||
Validated interface name
|
||||
|
||||
Raises:
|
||||
ValueError: If interface name is invalid
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Interface name is required")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
if not name:
|
||||
raise ValueError("Interface name cannot be empty")
|
||||
|
||||
if len(name) > 15:
|
||||
raise ValueError(f"Interface name too long (max 15 chars): {name}")
|
||||
|
||||
# Must start with letter, contain only alphanumeric/underscore/hyphen
|
||||
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', name):
|
||||
raise ValueError(f"Invalid interface name: {name}")
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def validate_bluetooth_interface(name: Any) -> str:
|
||||
"""
|
||||
Validate Bluetooth interface name (hciX format).
|
||||
|
||||
Args:
|
||||
name: Interface name to validate
|
||||
|
||||
Returns:
|
||||
Validated interface name
|
||||
|
||||
Raises:
|
||||
ValueError: If interface name is invalid
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Bluetooth interface name is required")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
# Must be hciX format where X is a number 0-255
|
||||
if not re.match(r'^hci([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$', name):
|
||||
raise ValueError(f"Invalid Bluetooth interface name (expected hciX): {name}")
|
||||
|
||||
return name
|
||||
|
||||
Reference in New Issue
Block a user