commit 94d8e201f57dfc3863e52f828f0b1fb84ad30fd1 Author: Juanjo Gutierrez Date: Wed Feb 4 14:14:28 2026 +0100 Initial commit diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..4cc0368 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,15 @@ +kind: pipeline +type: docker +name: default + +platform: + os: linux + arch: arm64 + +steps: + - name: publish + image: plugins/docker + settings: + repo: docker.gutierrezdequevedo.com/ps/hostapd-webui + tags: + - latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebcb6ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + + +# Environments +venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..39240f1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.14-alpine + +# Install system dependencies +RUN apk add --no-cache \ + hostapd \ + sudo + +# Create app directory +WORKDIR /app + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create non-root user for security +RUN adduser -D -u 1000 appuser && \ + chown -R appuser:appuser /app && \ + echo "appuser ALL=(ALL) NOPASSWD: /usr/bin/hostapd_cli" >> /etc/sudoers +USER appuser + +# Expose port +EXPOSE 5000 + +# Run application +CMD ["python", "app.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6b6b99 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Hostapd Web UI + +A Flask-based web application that provides a REST API interface to manage WiFi stations via hostapd. + +## Features + +- List connected WiFi stations (MAC addresses) +- Get detailed information about specific stations +- RESTful API with proper error handling +- JSON responses for easy integration + +## API Endpoints + +### `GET /api/v1/sta/list` +Returns a JSON array of connected station MAC addresses. + +**Response:** +```json +[ + "aa:bb:cc:dd:ee:ff", + "11:22:33:44:55:66" +] +``` + +### `GET /api/v1/sta/details/` +Returns detailed information about a specific station. + +**Response:** +```json +{ + "rx_packets": "1234", + "tx_packets": "5678", + "signal": "-65", + "connected_time": "3600" +} +``` + +### `GET /health` +Health check endpoint. + +**Response:** +```json +{ + "status": "healthy", + "service": "hostapd-webui" +} +``` + +## Requirements + +- Python 3.7+ +- Flask +- hostapd with hostapd_cli installed and configured + +## Error Handling + +The API returns appropriate HTTP status codes: +- 200: Success +- 400: Bad request (invalid MAC address format) +- 404: Station not found +- 500: Internal server error + +## Logging + +The application logs information and errors to the console. Set the `LOG_LEVEL` environment variable to control verbosity. + +## Development + +To run in debug mode, set `debug=True` in the `app.run()` call in `app.py`. diff --git a/app.py b/app.py new file mode 100644 index 0000000..4004890 --- /dev/null +++ b/app.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Hostapd Web UI - Flask Application +Provides REST API endpoints for managing WiFi stations via hostapd. +""" + +from flask import Flask, jsonify, request, render_template +from hostapd_client import HostapdClient, HostapdClientError +import logging + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize Flask app +app = Flask(__name__, template_folder="templates", static_folder="static") + +# Initialize hostapd client +# TODO: Make interface configurable via environment variable or config file +hostapd_client = HostapdClient(interface="wlan0") + + +@app.route("/api/v1/sta/list", methods=["GET"]) +def list_stations(): + """ + GET /api/v1/sta/list + Returns JSON array of connected station MAC addresses + + Returns: + JSON response with array of MAC addresses or error + """ + try: + stations = hostapd_client.list_stations() + logger.info(f"Retrieved {len(stations)} connected stations") + return jsonify(stations), 200 + except HostapdClientError as e: + logger.error(f"Failed to list stations: {str(e)}") + return jsonify({"error": f"Failed to retrieve station list: {str(e)}"}), 500 + except Exception as e: + logger.error(f"Unexpected error listing stations: {str(e)}") + return jsonify({"error": "Internal server error"}), 500 + + +@app.route("/api/v1/sta/details/", methods=["GET"]) +def get_station_details(mac): + """ + GET /api/v1/sta/details/ + Returns JSON object with detailed information about a specific station + + Args: + mac (str): MAC address of the station + + Returns: + JSON response with station details or error + """ + try: + details = hostapd_client.get_station_details(mac) + logger.info(f"Retrieved details for station {mac}") + return jsonify(details), 200 + except HostapdClientError as e: + logger.error(f"Failed to get details for {mac}: {str(e)}") + if "Invalid MAC address" in str(e): + return jsonify({"error": f"Invalid MAC address format: {mac}"}), 400 + elif "No details found" in str(e): + return jsonify({"error": f"Station not found: {mac}"}), 404 + else: + return ( + jsonify({"error": f"Failed to retrieve station details: {str(e)}"}), + 500, + ) + except Exception as e: + logger.error(f"Unexpected error getting details for {mac}: {str(e)}") + return jsonify({"error": "Internal server error"}), 500 + + +# Error handlers +@app.errorhandler(404) +def not_found(error): + """Handle 404 Not Found errors""" + return jsonify({"error": "Endpoint not found"}), 404 + + +@app.errorhandler(500) +def internal_error(error): + """Handle 500 Internal Server Error""" + return jsonify({"error": "Internal server error"}), 500 + + +@app.errorhandler(Exception) +def handle_exception(e): + """Handle unhandled exceptions""" + logger.error(f"Unhandled exception: {str(e)}") + return jsonify({"error": "Internal server error"}), 500 + + +# Health check endpoint +@app.route("/health", methods=["GET"]) +def health_check(): + """Health check endpoint""" + return jsonify({"status": "healthy", "service": "hostapd-webui"}), 200 + + +# Serve the main web interface +@app.route("/", methods=["GET"]) +def index(): + """Serve the main web interface""" + return render_template("index.html") + + +# API endpoint with documentation +@app.route("/api", methods=["GET"]) +def api_docs(): + """API documentation endpoint""" + return ( + jsonify( + { + "service": "hostapd-webui", + "version": "1.0.0", + "endpoints": { + "GET /api/v1/sta/list": "Get list of connected stations", + "GET /api/v1/sta/details/": "Get details for specific station", + "GET /health": "Health check", + }, + } + ), + 200, + ) + + +if __name__ == "__main__": + # Run the Flask app + # TODO: Make host/port configurable via environment variables + app.run(host="0.0.0.0", port=5000, debug=False) diff --git a/hostapd_client.py b/hostapd_client.py new file mode 100644 index 0000000..b0f249a --- /dev/null +++ b/hostapd_client.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Hostapd Client Module +Provides interface to hostapd_cli commands for managing WiFi stations. +""" + +import subprocess +import json +import logging +import os +from typing import List, Dict, Optional + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class HostapdClientError(Exception): + """Custom exception for hostapd client errors""" + + pass + + +class HostapdClient: + def __init__(self, interface: str = "wlan0"): + """ + Initialize HostapdClient + + Args: + interface (str): Network interface name (default: wlan0) + """ + self.interface = interface + self.hostapd_cli_path = "hostapd_cli" + + def _is_root(self) -> bool: + """ + Check if the current user is root (UID 0) + + Returns: + bool: True if running as root, False otherwise + """ + return os.geteuid() == 0 + + def _run_hostapd_command(self, args: List[str], timeout: int = 10) -> str: + """ + Execute hostapd_cli command with error handling + + Args: + args (List[str]): Command arguments + timeout (int): Timeout in seconds + + Returns: + str: Command output + + Raises: + HostapdClientError: If command fails + """ + try: + cmd = ["-s", "/run/hostapd"] + args + # Build command, adding sudo if not running as root + cmd.insert(0, self.hostapd_cli_path) + if not self._is_root(): + cmd.insert(0, "-n") # make sudo non interactive + cmd.insert(0, "sudo") + + logger.debug(f"Executing command: {' '.join(cmd)}") + + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout + ) + + if result.returncode != 0: + error_msg = ( + result.stderr.strip() + or f"Command failed with return code {result.returncode}" + ) + raise HostapdClientError(f"hostapd_cli error: {error_msg}") + + return result.stdout.strip() + + except subprocess.TimeoutExpired: + raise HostapdClientError("hostapd_cli command timed out") + except FileNotFoundError: + raise HostapdClientError( + "hostapd_cli command not found. Is hostapd installed?" + ) + except Exception as e: + raise HostapdClientError(f"Failed to execute hostapd_cli: {str(e)}") + + def list_stations(self) -> List[str]: + """ + Get list of connected stations (MAC addresses) + + Returns: + List[str]: List of MAC addresses + + Raises: + HostapdClientError: If command fails + """ + try: + output = self._run_hostapd_command(["-i", self.interface, "list_sta"]) + + if not output: + return [] + + # Split by newline and filter out empty lines + stations = [line.strip() for line in output.split("\n") if line.strip()] + return stations + + except HostapdClientError: + raise + except Exception as e: + raise HostapdClientError(f"Failed to parse station list: {str(e)}") + + def get_station_details(self, mac_address: str) -> Dict[str, str]: + """ + Get detailed information about a specific station + + Args: + mac_address (str): MAC address of the station + + Returns: + Dict[str, str]: Dictionary of station details + + Raises: + HostapdClientError: If command fails or MAC is invalid + """ + # Basic MAC address validation + if not mac_address or not isinstance(mac_address, str): + raise HostapdClientError("Invalid MAC address") + + # Simple format validation (xx:xx:xx:xx:xx:xx) + mac_parts = mac_address.split(":") + if len(mac_parts) != 6 or not all(len(part) == 2 for part in mac_parts): + raise HostapdClientError("Invalid MAC address format") + + try: + output = self._run_hostapd_command( + ["-i", self.interface, "sta", mac_address] + ) + + if not output: + raise HostapdClientError(f"No details found for station {mac_address}") + + # Parse key-value pairs from hostapd output + details = {} + for line in output.split("\n"): + if "=" in line: + key, value = line.split("=", 1) + details[key.strip()] = value.strip() + elif line.strip(): + # Handle lines without '=' (might be flags or status info) + details[line.strip()] = "true" + + return details + + except HostapdClientError: + raise + except Exception as e: + raise HostapdClientError(f"Failed to parse station details: {str(e)}") + + +# Example usage (for testing) +if __name__ == "__main__": + client = HostapdClient() + + try: + # Test listing stations + stations = client.list_stations() + print("Connected stations:", stations) + + # Test getting details for first station (if any) + if stations: + details = client.get_station_details(stations[0]) + print(f"Details for {stations[0]}:", json.dumps(details, indent=2)) + + except HostapdClientError as e: + print(f"Error: {e}") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cc35792 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask==2.3.3 \ No newline at end of file diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..f2b6a1b --- /dev/null +++ b/static/script.js @@ -0,0 +1,231 @@ +// Hostapd Web UI JavaScript +// Handles API communication and dynamic UI updates + +const API_BASE_URL = '/api/v1/sta'; +const REFRESH_INTERVAL = 2000; + +let isRefreshing = false; + +// DOM Elements +const stationTableBody = document.getElementById('station-tbody'); +const errorMessage = document.getElementById('error-message'); +const lastUpdate = document.getElementById('last-update'); + +// Initialize the application +document.addEventListener('DOMContentLoaded', function() { + // Load initial data + refreshStations(); + + startAutoRefresh(); +}); + +// Start auto-refresh interval +function startAutoRefresh() { + setInterval(refreshStations, REFRESH_INTERVAL); +} + +// Refresh station data +async function refreshStations() { + if (isRefreshing) return; // Prevent concurrent refreshes + + isRefreshing = true; + + try { + // Fetch station list + const stations = await fetchStations(); + + if (stations.length === 0) { + showNoStations(); + } else { + // Fetch details for each station and populate table + await populateStationTable(stations); + } + + // Update last refresh time + lastUpdate.textContent = `Last update: ${new Date().toLocaleTimeString()}`; + + // Hide any previous error messages + hideError(); + + } catch (error) { + console.error('Error refreshing stations:', error); + showError('Failed to load station data. Please check if the API is running.'); + } finally { + isRefreshing = false; + } +} + +// Fetch list of connected stations +async function fetchStations() { + const response = await fetch(`${API_BASE_URL}/list`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +// Populate table with station data +async function populateStationTable(stations) { + // Process each station concurrently + const stationPromises = stations.map(async (mac) => { + try { + const details = await fetchStationDetails(mac); + return { mac, details, error: null }; + } catch (error) { + return { mac, details: null, error: error.message }; + } + }); + + const stationResults = await Promise.all(stationPromises); + + const rows = []; + // Add rows to table + stationResults.forEach(({ mac, details, error }) => { + const row = document.createElement('tr'); + + if (error) { + row.innerHTML = ` + ${mac} + Error: ${error} + `; + } else { + // Calculate total bytes + const rxBytes = parseInt(details.rx_bytes) || 0; + const txBytes = parseInt(details.tx_bytes) || 0; + + // Determine signal strength category + const signal = parseInt(details.signal) || 0; + let signalClass = 'status-established'; + if (signal < -80) signalClass = 'status-new'; + else if (signal < -60) signalClass = 'status-medium'; + + row.innerHTML = ` + ${mac} + ${signal} dBm + ${formatDataRate(details.rx_rate_info)}/${formatDataRate(details.tx_rate_info)} + ${formatBytes(rxBytes)}/${formatBytes(txBytes)} + `; + } + rows.push(row); + }); + stationTableBody.replaceChildren(...rows); +} + +// Fetch details for a specific station +async function fetchStationDetails(mac) { + const response = await fetch(`${API_BASE_URL}/details/${mac}`); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Station not found'); + } else if (response.status === 400) { + throw new Error('Invalid MAC address'); + } else { + throw new Error(`HTTP error! status: ${response.status}`); + } + } + + return await response.json(); +} + +// Show message when no stations are connected +function showNoStations() { + stationTableBody.innerHTML = ` + + No stations connected + + `; +} + +// Show error message +function showError(message) { + errorMessage.querySelector('p').textContent = message; + errorMessage.classList.remove('hidden'); +} + +// Hide error message +function hideError() { + errorMessage.classList.add('hidden'); +} + +// Format data rate (assuming it's in Mbps or similar) +function formatDataRate(rateStr) { + if (!rateStr) return 'N/A'; + + const rate = parseInt(rateStr); + if (isNaN(rate)) return rateStr; + + // Convert to Mbps if it seems to be in a different unit + if (rate > 1000) { + return `${(rate / 1000).toFixed(1)} Mbps`; + } + return `${rate} Mbps`; +} + +// Format bytes to human readable format +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + if (!bytes) return 'N/A'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +// Format duration from seconds to human readable format +function formatDuration(secondsStr) { + if (!secondsStr) return 'N/A'; + + const seconds = parseInt(secondsStr); + if (isNaN(seconds) || seconds <= 0) return 'Just now'; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } else if (minutes > 0) { + return `${minutes}m ${secs}s`; + } else { + return `${secs}s`; + } +} + +// Get connection status based on connection time +function getConnectionStatus(connectedTimeStr) { + if (!connectedTimeStr) return 'Unknown'; + + const seconds = parseInt(connectedTimeStr); + if (isNaN(seconds)) return 'Connected'; + + if (seconds < 60) return 'New'; + else if (seconds < 300) return 'Medium'; + else return 'Established'; +} + +// Get CSS class for connection status +function getConnectionStatusClass(connectedTimeStr) { + if (!connectedTimeStr) return 'status-established'; + + const seconds = parseInt(connectedTimeStr); + if (isNaN(seconds)) return 'status-established'; + + if (seconds < 60) return 'status-new'; + else if (seconds < 300) return 'status-medium'; + else return 'status-established'; +} + +// Add CSS classes for status indicators (in case they're not in the CSS) +const style = document.createElement('style'); +style.textContent = ` + .status-connected { color: #27ae60; font-weight: bold; } + .status-connecting { color: #f39c12; font-weight: bold; } + .status-disconnected { color: #7f8c8d; font-weight: bold; } + .status-error { color: #e74c3c; font-weight: bold; } +`; +document.head.appendChild(style); diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..72bdb69 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,197 @@ +/* Reset and base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + line-height: 1.6; + color: #333; + background-color: #f5f5f5; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* Header styles */ +header { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + margin-bottom: 20px; +} + +header h1 { + color: #2c3e50; + margin-bottom: 10px; +} + +.status-bar { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.9em; + color: #666; +} + +/* Controls */ +.controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + background: white; + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.btn-primary { + background-color: #3498db; + color: white; + border: none; + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.3s; +} + +.btn-primary:hover { + background-color: #2980b9; +} + +.auto-refresh label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +/* Table styles */ +.station-table-container { + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + overflow: hidden; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th { + background-color: #34495e; + color: white; + text-align: left; + padding: 12px 15px; + font-weight: 600; +} + +td { + padding: 12px 15px; + border-bottom: 1px solid #ecf0f1; +} + +tr:last-child td { + border-bottom: none; +} + +tr:hover { + background-color: #f8f9fa; +} + +/* Status colors */ +.status-new { + background-color: #e74c3c; + color: white; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.8em; + font-weight: bold; + text-align: center; +} + +.status-medium { + background-color: #f39c12; + color: white; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.8em; + font-weight: bold; + text-align: center; +} + +.status-established { + background-color: #27ae60; + color: white; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.8em; + font-weight: bold; + text-align: center; +} + +/* Loading and error states */ +.loading { + text-align: center; + padding: 40px; + color: #7f8c8d; + font-style: italic; +} + +.error { + background-color: #fadbd8; + color: #c0392b; + padding: 15px; + border-radius: 4px; + margin: 20px 0; + text-align: center; +} + +.hidden { + display: none; +} + +/* Footer */ +footer { + text-align: center; + margin-top: 30px; + padding: 20px; + color: #7f8c8d; + font-size: 0.9em; +} + +/* Responsive design */ +@media (max-width: 768px) { + .container { + padding: 10px; + } + + table { + font-size: 0.9em; + } + + th, td { + padding: 8px 10px; + } + + .controls { + flex-direction: column; + gap: 10px; + text-align: center; + } + + .status-bar { + flex-direction: column; + gap: 5px; + text-align: center; + } +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..95a8005 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,49 @@ + + + + + + Hostapd Web UI - WiFi Station Monitor + + + +
+
+

WiFi Station Monitor

+
+ Last update: Never +
+
+ +
+
+ + + + + + + + + + + + + + +
MAC AddressSignal StrengthRate(rx/tx)Bytes(rx/tx)
Loading station data...
+
+ + +
+ +
+

Hostapd Web UI v1.0.0

+
+
+ + + +