Создание своего CaptivePortal с помощью роутера keenetic start и сервера UAM на ubuntu 24

Задача - сделать авторизацию клиентов в гостевой сети по коду из sms. Схема подключени клиента к гостевой сети будет примерно такой: подключение клиента к wifi введя пароль, CaptivePortal на keenetic автоматически перенаправляет клиента на  страницу UAM сервера где нужно ввести свой номер телефона и подтвердить его кодом из смс, при верном коде UAM сервер заводит пользователя в поле имя которого пишет номер телефона, далее keenetic проверяет разрешен ли доступ в radius, при разрешении выдается доступ к wifi на 15 минут.

1. Первый этап. Нам нужно настроить отправку смс клиентам.

Для этого используется модем Huawei E1550. Так как мой сервер ubuntu на виртуалке будем продрасывать модем туда по usbip. Общяа интрукция проброса тут:

https://onedayadmin.sytes.net/%d0%bf%d1%80%d0%be%d0%b1%d1%80%d0%be%d1%81-usb-%d0%bf%d0%be%d1%80%d1%82%d0%b0-%d0%bf%d0%be-%d1%81%d0%b5%d1%82%d0%b8/

Настройка отправки смс с помощью gammu

https://onedayadmin.sytes.net/%d0%bf%d0%be%d0%b4%d0%ba%d0%bb%d1%8e%d1%87%d0%b5%d0%bd%d0%b8%d0%b5-usb-gsm-%d0%bc%d0%be%d0%b4%d0%b5%d0%bc%d0%b0-%d0%bd%d0%b0-ubuntu/

2. Второй этап установим freeradius на ubuntu

Задаём статик IP Ubuntu

nano /etc/netplan/50-cloud-init.yaml

network:

version: 2

ethernets:

eth0:

addresses:

- "192.168.7.10/24"

nameservers:

addresses:

- 192.168.7.1

search: []

routes:

- to: "default"

via: "192.168.7.1"

Устанавливаем пакеты

apt install -y \ freeradius \ freeradius-utils \ nginx \ python3 \ python3-venv \ python3-pip \ sqlite3 \ curl

Проверяем:

freeradius –v

пакет FreeRADIUS сейчас находится в ветке 3.2.x

Настраиваем FreeRADIUS

сделаем резервную копию:

cp /etc/freeradius/3.0/clients.conf \ /etc/freeradius/3.0/clients.conf.bak

добавим в клиенты наш keenetic

nano /etc/freeradius/3.0/clients.conf

В конец добавляем:

client keenetic {

ipaddr = 192.168.7.94

secret = MyRadiusSecret_2026

shortname = keenetic-start

nastype = other

}

IP здесь должен быть адресом Keenetic, с которого он отправляет RADIUS-запросы.

Проверяем конфигурацию

freeradius –XC

В конце должно быть что-нибудь вроде:

Configuration appears to be OK

Для диагностики FreeRADIUS рекомендует запуск в debug-режиме:

freeradius –X

Проверяем RADIUS без Keenetic

В другом SSH/терминале:

radtest testuser 123456 127.0.0.1 0 testing123

По дефолту эта настройка активна

Ожидаем:

Received Access-Accept

3. Третий этап. Установим и настроим MariaDB для хранения всех данных.

Теперь создаём отдельную БД для FreeRADIUS и пользователя в MariaDB.

CREATE DATABASE radius CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE USER 'radius'@'localhost' IDENTIFIED BY 'RadiusPass';

GRANT ALL PRIVILEGES ON radius.* TO 'radius'@'localhost';

FLUSH PRIVILEGES;

EXIT;

Теперь проверим подключение именно этим пользователем:

mariadb -u radius -p radius SELECT DATABASE();

Следующий шаг — установить MYSQL-модуль FreeRADIUS и загрузить штатную схему таблиц

apt install freeradius-mysql

Затем проверим, появился ли модуль:

ls -l /etc/freeradius/3.0/mods-available/sql

И посмотрим, какие модули сейчас включены:

ls -l /etc/freeradius/3.0/mods-enabled/ | grep sql

Включаем модуль

ln -s /etc/freeradius/3.0/mods-available/sql  /etc/freeradius/3.0/mods-enabled/sql

Теперь настроим SQL-модуль на MariaDB.

nano /etc/freeradius/3.0/mods-enabled/sql

Замените параметры:

driver = "rlm_sql_null"dialect = ${modules.sql.dialect}

и замените на:

driver = "rlm_sql_mysql"dialect = "mysql"

Далее найдите блок подключения к базе, примерно такой:

server = "localhost"port = 3306login = "radius"password = "..."database = "radius"

Укажите:

server = "localhost"port = 3306login = "radius"password = "RadiusPass"database = "radius"

Загружаем штатные таблицы FreeRADIUS

dpkg -L freeradius-mysql | grep schema.sql

если ничего не нашло

sudo find /usr/share /etc/freeradius -type f \( -name 'schema.sql' -o -name '*mysql*.sql' \) 2>/dev/null

у нас это

mariadb radius < /etc/freeradius/3.0/mods-config/sql/main/mysql/schema.sql

Проверим таблицы:

sudo mariadb radius -e "SHOW TABLES;"

проверить, что FreeRADIUS действительно может подключиться к MariaDB.

freeradius –XC

если включен TLS отключаем его, он не нужен

sudo nano /etc/freeradius/3.0/mods-enabled/sql

заменить:

mysql {        tls {                ca_file = "/etc/ssl/certs/my_ca.crt"                ca_path = "/etc/ssl/certs/"                certificate_file = "/etc/ssl/certs/private/client.crt"                private_key_file = "/etc/ssl/certs/private/client.key"                cipher = "DHE-RSA-AES256-SHA:AES128-SHA"                 tls_required = yes                tls_check_cert = no                tls_check_cert_cn = no        }         warnings = auto}

На:

mysql {        warnings = auto}

Должно быть Configuration appears to be OK

sudo nano /etc/freeradius/3.0/sites-enabled/default

в секции

authorize {

заменить

        -sql

на:

sql

sudo freeradius –XC

4. Этап.  Создадим наш UAM сервер.

Скрипт будет на Pyton

Через Python-библиотеку pyrad Flask будет отправлять в FreeRADIUS Access-Request только с номером телефона, без User-Password

создадим директорию для flask

mkdir /opt/keenetic-uam

chown $USER:$USER /opt/keenetic-uam

cd /opt/keenetic-uam

Создаём virtualenv:

python3 -m venv venv

source venv/bin/activate

Устанавливаем Flask и RADIUS-клиент:

pip install flask

Установим pyrad

apt install python3-pyrad

проверим

python3 -c "import pyrad; print('pyrad OK')"

nano /opt/keenetic-uam/app.py

#!/usr/bin/env python3

import os
import re
import time
import hmac
import hashlib
import secrets
import sqlite3
import subprocess
import urllib.parse

from flask import Flask, request, redirect, render_template_string

from pyrad.client import Client
from pyrad.dictionary import Dictionary
from pyrad.packet import AccessRequest, AccessAccept, AccessReject

app = Flask(__name__)

# ============================================================
# Основные настройки
# ============================================================

DB_PATH = "/opt/keenetic-uam/otp.db"
GAMMU_CONFIG = "/home/root/gammurc"

# Должен совпадать с UAM Secret на Keenetic
UAM_SECRET = "UAMPASSWORD"

OTP_TTL = 300 # 5 минут
MAX_ATTEMPTS = 5

# ============================================================
# Keenetic UAM
#
# Пароль используется только для текущего UAM /logon CHAP.
# Username пользователя здесь больше не хранится:
# username = номер телефона.
# ============================================================

#KEENETIC_PASSWORD = "R17Bc0mSh"

# ============================================================
# FreeRADIUS
# ============================================================

RADIUS_HOST = "127.0.0.1"
RADIUS_PORT = 1812

# Должен совпадать с secret соответствующего client
# в конфигурации FreeRADIUS.
RADIUS_SECRET = b"MyRadiusSecret_2026"

RADIUS_DICTIONARY = "/opt/keenetic-uam/radius_dictionary"

# ============================================================
# HTML
# ============================================================

HTML = """
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Wi-Fi доступ</title>

<style>
* {
box-sizing: border-box;
}

html, body {
margin: 0;
padding: 0;
min-height: 100%;
}

body {
min-height: 100vh;

font-family: Arial, sans-serif;

background-image:
linear-gradient(
rgba(0, 0, 0, 0.35),
rgba(0, 0, 0, 0.35)
),
url("/static/background.png");

background-size: cover;
background-position: center;
background-repeat: no-repeat;

display: flex;
justify-content: center;
align-items: center;

padding: 20px;
}

.login-box {
width: 100%;
max-width: 420px;
background: rgba(255, 255, 255, 0.20);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 18px;
padding: 30px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.30);

backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}

h2 {
text-align: center;
margin-top: 0;
}

input,
button {
width: 100%;
padding: 13px;
margin-top: 12px;

font-size: 16px;
border-radius: 8px;
border: 1px solid #ccc;
}

button {
border: none;
background: #1677ff;
color: white;
cursor: pointer;
}

button:hover {
background: #0d5dcc;
}

button:disabled {
opacity: 0.6;
cursor: default;
}

.error {
color: #b00020;
margin: 15px 0;
}

.info {
color: #333;
margin: 15px 0;
}
</style>
</head>
<body>

<div class="login-box">

<h2>Доступ к Wi-Fi</h2>

{% if error %}
<div class="error">{{ error }}</div>
{% endif %}

{% if info %}
<div class="info">{{ info }}</div>
{% endif %}

{{ body|safe }}

</div>

</body>
</html>
"""

# ============================================================
# SQLite
# ============================================================

def db_connect():
connection = sqlite3.connect(DB_PATH)
connection.row_factory = sqlite3.Row
return connection

def init_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)

with db_connect() as db:
db.execute("""
CREATE TABLE IF NOT EXISTS otp_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL,
otp_hash TEXT NOT NULL,
expires_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
used INTEGER NOT NULL DEFAULT 0,

uamip TEXT,
uamport TEXT,
challenge TEXT,
userurl TEXT,
mac TEXT,
client_ip TEXT,
nasid TEXT,
sessionid TEXT,

created_at INTEGER NOT NULL
)
""")

db.commit()

# ============================================================
# RADIUS dictionary
# ============================================================

def init_radius_dictionary():

os.makedirs(
os.path.dirname(RADIUS_DICTIONARY),
exist_ok=True
)

dictionary_content = """ATTRIBUTE User-Name 1 string
ATTRIBUTE NAS-IP-Address 4 ipaddr
ATTRIBUTE Calling-Station-Id 31 string
"""

try:
with open(
RADIUS_DICTIONARY,
"w",
encoding="utf-8"
) as file:
file.write(dictionary_content)

except OSError as error:
app.logger.error(
"Cannot create RADIUS dictionary: %s",
error
)

raise

# ============================================================
# Phone
# ============================================================

def normalize_phone(phone):

phone = phone.strip()

phone = re.sub(
r"[ \-()]",
"",
phone
)

if phone.startswith("00"):
phone = "+" + phone[2:]

if not re.fullmatch(
r"\+[0-9]{8,15}",
phone
):
return None

return phone

# ============================================================
# OTP
# ============================================================

def hash_otp(otp):

return hashlib.sha256(
otp.encode("utf-8")
).hexdigest()

# ============================================================
# SMS
# ============================================================

def send_sms(phone, message):

app.logger.info(
"Sending SMS to %s",
phone
)

command = [
"gammu",
"--config",
GAMMU_CONFIG,
"sendsms",
"TEXT",
phone,
"-unicode",
"-text",
message
]

result = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60
)

if result.returncode != 0:

error_text = (
result.stderr.strip()
or result.stdout.strip()
)

app.logger.error(
"Gammu SMS error: %s",
error_text or "unknown error"
)

raise RuntimeError(
error_text or "Gammu не смог отправить SMS"
)

app.logger.info(
"SMS successfully sent to %s",
phone
)

# ============================================================
# MariaDB / radcheck
#
# Используем стандартную таблицу FreeRADIUS.
#
# Логика:
#
# номера нет -> INSERT Accept
# уже Accept -> ничего
# уже Reject -> ничего
#
# Поэтому правильный SMS никогда автоматически
# не снимает административный запрет.
# ============================================================

def ensure_radius_user(phone):

app.logger.info(
"Checking radcheck entry for %s",
phone
)

# Значение телефона содержит только + и цифры,
# поэтому после normalize_phone оно безопасно
# для этого SQL-запроса.
escaped_phone = phone.replace("'", "''")

select_sql = (
"SELECT value "
"FROM radcheck "
"WHERE username = '"
+ escaped_phone
+ "' "
"AND attribute = 'Auth-Type' "
"ORDER BY id "
"LIMIT 1;"
)

try:

result = subprocess.run(
[
"mariadb",
"--batch",
"--skip-column-names",
"radius",
"-e",
select_sql
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=10
)

except subprocess.TimeoutExpired:

app.logger.error(
"MariaDB query timeout for %s",
phone
)

return False

if result.returncode != 0:

app.logger.error(
"MariaDB SELECT error: %s",
result.stderr.strip()
)

return False

value = result.stdout.strip()

# --------------------------------------------------------
# Пользователь ещё не существует.
# Добавляем разрешение.
# --------------------------------------------------------

if not value:

insert_sql = (
"INSERT INTO radcheck "
"(username, attribute, op, value) "
"VALUES ('"
+ escaped_phone
+ "', 'Auth-Type', ':=', 'Accept');"
)

try:

result = subprocess.run(
[
"mariadb",
"radius",
"-e",
insert_sql
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=10
)

except subprocess.TimeoutExpired:

app.logger.error(
"MariaDB INSERT timeout for %s",
phone
)

return False

if result.returncode != 0:

app.logger.error(
"MariaDB INSERT error: %s",
result.stderr.strip()
)

return False

app.logger.info(
"New RADIUS user added: %s -> Accept",
phone
)

return True

# --------------------------------------------------------
# Пользователь уже существует.
# --------------------------------------------------------

if value.lower() == "accept":

app.logger.info(
"RADIUS user %s already enabled",
phone
)

return True

if value.lower() == "reject":

app.logger.warning(
"RADIUS user %s is administratively blocked",
phone
)

# Ничего не меняем!
return True

# Неизвестное значение Auth-Type.
app.logger.error(
"Unexpected Auth-Type value for %s: %s",
phone,
value
)

return False

# ============================================================
# FreeRADIUS
#
# В запросе НЕТ User-Password.
#
# FreeRADIUS проверяет username через radcheck.
# ============================================================

def check_radius(username, calling_station_id=None):

app.logger.info(
"Checking RADIUS authorization for user %s",
username
)

try:

dictionary = Dictionary(
RADIUS_DICTIONARY
)

client = Client(
server=RADIUS_HOST,
secret=RADIUS_SECRET,
dict=dictionary
)

client.authport = RADIUS_PORT

request_packet = client.CreateAuthPacket(
code=AccessRequest,
User_Name=username
)

request_packet["NAS-IP-Address"] = RADIUS_HOST

if calling_station_id:

request_packet["Calling-Station-Id"] = (
calling_station_id
)

response = client.SendPacket(
request_packet
)

if response.code == AccessAccept:

app.logger.info(
"RADIUS Access-Accept for user %s",
username
)

return True

if response.code == AccessReject:

app.logger.warning(
"RADIUS Access-Reject for user %s",
username
)

return False

app.logger.warning(
"Unexpected RADIUS response code %s for %s",
response.code,
username
)

return False

except Exception as error:

app.logger.error(
"RADIUS error for user %s: %s",
username,
error
)

return False

# ============================================================
# Keenetic UAM CHAP
# ============================================================

def calculate_chap_response(
challenge,
password
):

challenge_bytes = bytes.fromhex(
challenge
)

new_challenge = hashlib.md5(
challenge_bytes
+ UAM_SECRET.encode("utf-8")
).digest()

response = hashlib.md5(
b"\x00"
+ password.encode("utf-8")
+ new_challenge
).hexdigest()

return response

def make_keenetic_redirect(session):

challenge = session["challenge"]

response = calculate_chap_response(
challenge,
KEENETIC_PASSWORD
)

# Передаём в CoovaChilli именно номер телефона.
# Это username, который затем попадёт
# в RADIUS и в radacct.
phone = session["phone"]

params = {
"username": phone,
"response": response,
"userurl": (
session["userurl"]
or "http://example.com"
)
}

url = (
f"http://{session['uamip']}:{session['uamport']}/logon?"
+ urllib.parse.urlencode(params)
)

app.logger.info(
"Redirecting authenticated user %s to Keenetic UAM",
phone
)

return redirect(url)

# ============================================================
# /health
# ============================================================

@app.route("/health")
def health():

return "OK", 200

# ============================================================
# /login
# ============================================================

@app.route(
"/login",
methods=["GET"]
)
def login():

if request.args.get("res") == "success":

app.logger.info(
"Keenetic reports successful authentication for user %s",
request.args.get(
"uid",
"unknown"
)
)

return render_template_string(
HTML,
error=None,
info="Доступ к Wi-Fi разрешён.",
body=""
)

if request.args.get("res") == "failed":

app.logger.warning(
"Keenetic reports authentication failure"
)

return render_template_string(
HTML,
error="Авторизация не удалась.",
info=None,
body=""
)

params = {
"uamip": request.args.get(
"uamip",
""
),
"uamport": request.args.get(
"uamport",
""
),
"challenge": request.args.get(
"challenge",
""
),
"userurl": request.args.get(
"userurl",
""
),
"mac": request.args.get(
"mac",
""
),
"ip": request.args.get(
"ip",
""
),
"nasid": request.args.get(
"nasid",
""
),
"sessionid": request.args.get(
"sessionid",
""
)
}

app.logger.info(
"New UAM login request: MAC=%s IP=%s Session=%s",
params["mac"],
params["ip"],
params["sessionid"]
)

body = """
<p>Введите номер телефона для получения кода:</p>

<form
method="post"
action="/send-code"
id="phone-form"
>

<input
type="tel"
name="phone"
placeholder="+79991234567"
required
>

{% for key, value in params.items() %}
<input
type="hidden"
name="{{ key }}"
value="{{ value }}"
>
{% endfor %}

<button
type="submit"
id="send-code-button"
>
Получить код
</button>

</form>

<script>
(function () {
const form = document.getElementById("phone-form");
const button = document.getElementById("send-code-button");

if (!form || !button) {
return;
}

form.addEventListener("submit", function () {

if (button.disabled) {
return;
}

// Блокируем кнопку СРАЗУ при отправке формы,
// до фактического POST /send-code.
button.disabled = true;
button.textContent = "Отправка...";
});
})();
</script>
"""

return render_template_string(
HTML,
error=None,
info=None,
body=render_template_string(
body,
params=params
)
)

# ============================================================
# /send-code
# ============================================================

@app.route(
"/send-code",
methods=["POST"]
)
def send_code():

phone = normalize_phone(
request.form.get(
"phone",
""
)
)

if not phone:

app.logger.warning(
"Invalid phone number submitted"
)

return render_template_string(
HTML,
error=(
"Введите номер в международном формате, "
"например +79991234567."
),
info=None,
body=""
)

otp = str(
secrets.randbelow(900000)
+ 100000
)

now = int(time.time())

with db_connect() as db:

cursor = db.execute(
"""
INSERT INTO otp_sessions (
phone,
otp_hash,
expires_at,
uamip,
uamport,
challenge,
userurl,
mac,
client_ip,
nasid,
sessionid,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
phone,
hash_otp(otp),
now + OTP_TTL,
request.form.get(
"uamip",
""
),
request.form.get(
"uamport",
""
),
request.form.get(
"challenge",
""
),
request.form.get(
"userurl",
""
),
request.form.get(
"mac",
""
),
request.form.get(
"ip",
""
),
request.form.get(
"nasid",
""
),
request.form.get(
"sessionid",
""
),
now
)
)

token = cursor.lastrowid

db.commit()

app.logger.info(
"OTP generated for %s, session token=%s",
phone,
token
)

message = (
f"Wi-Fi код: {otp}. Действует 5 минут."
)

try:

send_sms(
phone,
message
)

except Exception as error:

with db_connect() as db:

db.execute(
"DELETE FROM otp_sessions WHERE id = ?",
(token,)
)

db.commit()

app.logger.error(
"SMS sending failed for session %s: %s",
token,
error
)

return render_template_string(
HTML,
error=f"Ошибка отправки SMS: {error}",
info=None,
body=""
)

body = f"""
<p>Код отправлен на номер <b>{phone}</b>.</p>

<form method="post" action="/verify-code">

<input
type="text"
name="otp"
inputmode="numeric"
pattern="[0-9]{{6}}"
maxlength="6"
placeholder="Введите код"
required
>

<input
type="hidden"
name="token"
value="{token}"
>

<button type="submit">
Подтвердить
</button>

</form>
"""

return render_template_string(
HTML,
error=None,
info=None,
body=body
)

# ============================================================
# /verify-code
# ============================================================

@app.route(
"/verify-code",
methods=["POST"]
)
def verify_code():

token = request.form.get(
"token",
""
)

entered_otp = request.form.get(
"otp",
""
).strip()

app.logger.info(
"OTP verification started, token=%s",
token
)

if (
not token.isdigit()
or not re.fullmatch(
r"[0-9]{6}",
entered_otp
)
):

app.logger.warning(
"Invalid OTP format for token=%s",
token
)

return render_template_string(
HTML,
error="Введите шестизначный код.",
info=None,
body=""
)

with db_connect() as db:

session = db.execute(
"SELECT * FROM otp_sessions WHERE id = ?",
(int(token),)
).fetchone()

if session is None:

app.logger.warning(
"OTP session not found: token=%s",
token
)

return render_template_string(
HTML,
error="Сессия не найдена.",
info=None,
body=""
)

if session["used"]:

app.logger.warning(
"OTP already used: token=%s",
token
)

return render_template_string(
HTML,
error="Этот код уже использован.",
info=None,
body=""
)

if int(time.time()) > session["expires_at"]:

app.logger.warning(
"OTP expired: token=%s",
token
)

return render_template_string(
HTML,
error="Срок действия кода истёк.",
info=None,
body=""
)

if session["attempts"] >= MAX_ATTEMPTS:

app.logger.warning(
"Too many OTP attempts: token=%s",
token
)

return render_template_string(
HTML,
error="Превышено количество попыток.",
info=None,
body=""
)

expected_hash = session["otp_hash"]

actual_hash = hash_otp(
entered_otp
)

if not hmac.compare_digest(
expected_hash,
actual_hash
):

db.execute(
"""
UPDATE otp_sessions
SET attempts = attempts + 1
WHERE id = ?
""",
(int(token),)
)

db.commit()

app.logger.warning(
"Invalid OTP: token=%s attempt=%s",
token,
session["attempts"] + 1
)

return render_template_string(
HTML,
error="Неверный код.",
info=None,
body=""
)

# =====================================================
# OTP успешно проверен.
# Используем именно номер телефона пользователя.
# =====================================================

phone = session["phone"]

app.logger.info(
"OTP verification successful for user %s",
phone
)

# =====================================================
# ВАЖНО: OTP не добавляет номер телефона в radcheck.
# OTP только подтверждает владение номером.
# Разрешён ли номер, решает FreeRADIUS.
# =====================================================

# =====================================================
# Проверяем пользователя через FreeRADIUS.
#
# User-Password отсутствует.
# =====================================================
if not ensure_radius_user(phone):
app.logger.error(
"Unable to authorize RADIUS user %s",
phone
)

return render_template_string(
HTML,
error="Не удалось проверить доступ к Wi-Fi.",
info=None,
body=""
)

radius_ok = check_radius(
phone,
session["mac"]
)

if not radius_ok:

app.logger.warning(
"OTP correct, but RADIUS rejected user %s",
phone
)

return render_template_string(
HTML,
error="Для этого номера доступ к Wi-Fi запрещён.",
info=None,
body=""
)

# =====================================================
# OTP + RADIUS успешно.
# =====================================================

app.logger.info(
"OTP + RADIUS authentication successful for user %s",
phone
)

# Только после успешного RADIUS
# делаем OTP использованным.
db.execute(
"""
UPDATE otp_sessions
SET used = 1
WHERE id = ?
""",
(int(token),)
)

db.commit()

# =========================================================
# RADIUS подтвердил пользователя.
# Отправляем Keenetic успешный UAM logon.
# =========================================================

return make_keenetic_redirect(
session
)

# ============================================================
# Запуск
# ============================================================

if __name__ == "__main__":

init_db()

init_radius_dictionary()

app.run(
host="127.0.0.1",
port=8080,
debug=False
)

Запускаем UAM

cd /opt/keenetic-uam

source venv/bin/activate

python app.py

Должно появиться:

Running on http://0.0.0.0:8080

С другого компьютера:

http://192.168.1.10:8080/login

Делаем systemd-сервис

nano /etc/systemd/system/keenetic-uam.service
[Unit]
Description=Keenetic UAM Captive Portal
After=network.target[Service]
User=root
WorkingDirectory=/opt/keenetic-uam
ExecStart=/opt/keenetic-uam/venv/bin/python /opt/keenetic-uam/app.py
Restart=always
RestartSec=3[Install]
WantedBy=multi-user.target

systemctl daemon-reload

systemctl enable --now keenetic-uam

Нужно в каталог /opt/keenetic-uam/static/background.png фоновое избражение .

Проверяем:

systemctl status keenetic-uam

5. Пятый этап. Развернем Nginx для web доступа к нашей странице авторизации.

apt-get install nginx

Устанавливаем reverse proxy:

nano /etc/nginx/sites-available/keenetic-uam

server {

listen 80;

server_name 192.168.1.10;

 

location / {

proxy_pass http://127.0.0.1:8080;

 

proxy_set_header Host $host;

proxy_set_header X-Real-IP $remote_addr;

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

}

}

 

Активируем:

ln -s \ /etc/nginx/sites-available/keenetic-uam  /etc/nginx/sites-enabled/keenetic-uam

Проверяем:

nginx –t

systemctl restart nginx

Проверяем

http://192.168.1.10/login

6. Шестой Этап. Создание статистики.

Создадим отдельного пользователя MariaDB

Лучше не давать статистическому приложению права записи:

sudo mariadb radius

В MariaDB:

CREATE USER 'radius_stat'@'localhost' IDENTIFIED BY 'PASSSTATISTIC';

GRANT SELECT ON radius.radacct TO 'radius_stat'@'localhost';

FLUSH PRIVILEGES;

EXIT;

Создадим директорию для приложения

mkdir -p /opt/uat/stat

apt install -y python3-venv python3-pip

Создали новое виртуальное окружение:

sudo python3 -m venv /opt/uat/venv

sudo /opt/uat/venv/bin/pip install flask pymysql

nano /opt/uat/app_stat.py

from flask import Flask, render_template, request
import pymysqlapp = Flask(
__name__,
template_folder="/opt/uat/stat"
)DB_CONFIG = {
"host": "127.0.0.1",
"user": "radius_stat",
"password": "FAsk0^lkH",
"database": "radius",
"charset": "utf8mb4",
"cursorclass": pymysql.cursors.DictCursor,
}def db_connect():
return pymysql.connect(**DB_CONFIG)@app.route("/stats/ap", methods=["GET", "POST"])
def ap_list():

if request.method == "POST":

ap_id = request.form.get("id", "").strip()
name = request.form.get("name", "").strip()
ip = request.form.get("ip", "").strip()

if ap_id:

with db_connect() as conn:
with conn.cursor() as cursor:

cursor.execute(
"""
UPDATE ap
SET name = %s,
ip = %s
WHERE id = %s
""",
(
name,
ip or None,
ap_id
)
)

conn.commit()

with db_connect() as conn:
with conn.cursor() as cursor:

cursor.execute(
"""
SELECT
id,
mac,
name,
ip
FROM ap
ORDER BY mac
"""
)

aps = cursor.fetchall()

return render_template(
"ap.html",
aps=aps
)

@app.route("/stats")
def stats():

phone = request.args.get(
"phone",
""
).strip()

ap = request.args.get(
"ap",
""
).strip()

date_from = request.args.get(
"date_from",
""
).strip()

date_to = request.args.get(
"date_to",
""
).strip()

where = []
params = []

if phone:

where.append(
"radacct.username LIKE %s"
)

params.append(
f"%{phone}%"
)

if ap:

where.append(
"radacct.calledstationid = %s"
)

params.append(
ap
)

if date_from:

where.append(
"radacct.acctstarttime >= %s"
)

params.append(
f"{date_from} 00:00:00"
)

if date_to:

# Включаем весь последний день
where.append(
"radacct.acctstarttime < DATE_ADD(%s, INTERVAL 1 DAY)"
)

params.append(
date_to
)

where_sql = ""

if where:

where_sql = (
"WHERE " +
" AND ".join(where)
)

# Основной запрос статистики
query = f"""
SELECT
radacct.radacctid,
radacct.username,
radacct.calledstationid,
ap.name AS ap_name,
radacct.callingstationid,
radacct.nasipaddress,
radacct.nasportid,
radacct.acctstarttime,
radacct.acctupdatetime,
radacct.acctstoptime,
radacct.acctsessiontime,
radacct.acctinputoctets,
radacct.acctoutputoctets,
radacct.acctterminatecause,
radacct.framedipaddress

FROM radacct

LEFT JOIN ap
ON ap.mac = radacct.calledstationid

{where_sql}

ORDER BY radacct.acctstarttime DESC

LIMIT 1000
"""

with db_connect() as conn:

with conn.cursor() as cursor:

# Получаем сессии
cursor.execute(
query,
params
)

sessions = cursor.fetchall()

# Список AP для фильтра
cursor.execute(
"""
SELECT
radacct.calledstationid,
ap.name AS ap_name

FROM radacct

LEFT JOIN ap
ON ap.mac = radacct.calledstationid

WHERE radacct.calledstationid <> ''

GROUP BY
radacct.calledstationid,
ap.name

ORDER BY
radacct.calledstationid
"""
)

aps = cursor.fetchall()

# Форматирование данных
for row in sessions:

seconds = (
row["acctsessiontime"]
or 0
)

if seconds:

hours = seconds // 3600

minutes = (
seconds % 3600
) // 60

secs = seconds % 60

if hours:

row["duration"] = (
f"{hours} ч "
f"{minutes} мин"
)

elif minutes:

row["duration"] = (
f"{minutes} мин "
f"{secs} сек"
)

else:

row["duration"] = (
f"{secs} сек"
)

else:

row["duration"] = "—"

input_bytes = (
row["acctinputoctets"]
or 0
)

output_bytes = (
row["acctoutputoctets"]
or 0
)

row["input_mb"] = round(
input_bytes / 1024 / 1024,
2
)

row["output_mb"] = round(
output_bytes / 1024 / 1024,
2
)

return render_template(
"stats.html",
sessions=sessions,
aps=aps,
phone=phone,
ap=ap,
date_from=date_from,
date_to=date_to,
)

if __name__ == "__main__":

app.run(
host="127.0.0.1",
port=8081,
debug=False
)

nano /opt/uat/stat/stats.html

<!DOCTYPE html>
<html lang="ru"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>Статистика Wi-Fi</title><style>
* {
box-sizing: border-box;
}body {
margin: 0;
padding: 25px;
font-family: Arial, sans-serif;
background: #f4f6f8;
color: #222;
}

.container {
max-width: 1500px;
margin: auto;
}

h1 {
margin-bottom: 20px;
}

.filters {
background: white;
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, .08);
}

.filters form {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: end;
}

.field {
display: flex;
flex-direction: column;
gap: 5px;
}

label {
font-size: 13px;
color: #666;
}

input,
select,
button {
height: 40px;
padding: 0 10px;
border: 1px solid #ccc;
border-radius: 7px;
font-size: 14px;
}

button {
background: #1677ff;
color: white;
border: none;
padding: 0 20px;
cursor: pointer;
}

button:hover {
background: #0d5dcc;
}

.table-wrapper {
background: white;
border-radius: 12px;
overflow-x: auto;
box-shadow: 0 2px 10px rgba(0, 0, 0, .08);
}

table {
width: 100%;
border-collapse: collapse;
min-width: 1100px;
}

th,
td {
padding: 11px 12px;
border-bottom: 1px solid #eee;
text-align: left;
white-space: nowrap;
}

th {
background: #f8f9fa;
font-size: 13px;
}

td {
font-size: 13px;
}

tr:hover {
background: #f8fbff;
}

.empty {
padding: 40px;
text-align: center;
color: #777;
}

.online {
color: #087f23;
font-weight: bold;
}

.timeout {
color: #b35a00;
}

.traffic {
text-align: right;
}

/* Название AP — кликабельная ссылка */
.ap-name {
color: #1677ff;
text-decoration: none;
font-weight: 600;
}

.ap-name:hover {
text-decoration: underline;
}
</style>
</head>

<body>

<div class="container">

<h1>Статистика Wi-Fi</h1>

<div class="filters">

<form method="get" action="/stats">

<!-- Телефон -->
<div class="field">

<label>Телефон</label>

<input
type="text"
name="phone"
value="{{ phone }}"
placeholder="+79595082552"
>

</div>

<!-- AP -->
<div class="field">

<label>AP</label>

<select name="ap">

<option value="">
Все точки
</option>

{% for item in aps %}

<option
value="{{ item.calledstationid }}"
{% if ap == item.calledstationid %}selected{% endif %}
>

{{ item.calledstationid }}

{% if item.ap_name %}
— {{ item.ap_name }}
{% endif %}

</option>

{% endfor %}

</select>

</div>

<!-- Дата начала -->
<div class="field">

<label>С даты</label>

<input
type="date"
name="date_from"
value="{{ date_from }}"
>

</div>

<!-- Дата окончания -->
<div class="field">

<label>По дату</label>

<input
type="date"
name="date_to"
value="{{ date_to }}"
>

</div>

<button type="submit">
Показать
</button>

</form>

</div>

<!-- Таблица -->

<div class="table-wrapper">

{% if sessions %}

<table>

<thead>

<tr>
<th>Начало</th>
<th>Телефон</th>
<th>AP</th>
<th>MAC клиента</th>
<th>IP</th>
<th>Окончание</th>
<th>Длительность</th>
<th>Входящий MB</th>
<th>Исходящий MB</th>
<th>Причина</th>
</tr>

</thead>

<tbody>

{% for row in sessions %}

<tr>

<!-- Начало -->

<td>
{{ row.acctstarttime or "—" }}
</td>

<!-- Телефон -->

<td>
{{ row.username }}
</td>

<!-- AP -->

<td>

{{ row.calledstationid }}

{% if row.ap_name %}

—
<a
href="/stats/ap"
class="ap-name"
>
{{ row.ap_name }}
</a>

{% endif %}

</td>

<!-- MAC клиента -->

<td>
{{ row.callingstationid }}
</td>

<!-- IP клиента -->

<td>
{{ row.framedipaddress }}
</td>

<!-- Окончание -->

<td>

{% if row.acctstoptime %}

{{ row.acctstoptime }}

{% else %}

<span class="online">
онлайн
</span>

{% endif %}

</td>

<!-- Длительность -->

<td>
{{ row.duration }}
</td>

<!-- Входящий трафик -->

<td class="traffic">
{{ row.input_mb }}
</td>

<!-- Исходящий трафик -->

<td class="traffic">
{{ row.output_mb }}
</td>

<!-- Причина завершения -->

<td>

{% if row.acctterminatecause == "Session-Timeout" %}

<span class="timeout">
Session-Timeout
</span>

{% else %}

{{ row.acctterminatecause or "—" }}

{% endif %}

</td>

</tr>

{% endfor %}

</tbody>

</table>

{% else %}

<div class="empty">
Сессий не найдено
</div>

{% endif %}

</div>

</div>

</body>

</html>

Сначала запускаем вручную

Не делаем systemd и nginx, пока не убедимся, что само приложение работает

/opt/uat/venv/bin/python /opt/uat/stat/app_stat.py

проверим curl

http://127.0.0.1:8081/stats | head

Добавляем в NGINX

 nano /etc/nginx/sites-enabled/keenetic-uam

Сделай так:

server {
listen 80;
server_name 192.168.7.10;location /stats {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
systemctl restart nginx

Создадим службу в  systemd

nano /etc/systemd/system/keenetic-stat.service
[Unit]
Description=Keenetic Wi-Fi Statistics
After=network.target mariadb.service
Requires=mariadb.service[Service]
Type=simple
User=root
WorkingDirectory=/opt/uat/stat
ExecStart=/opt/uat/venv/bin/python /opt/uat/stat/app_stat.py
Restart=always
RestartSec=3[Install]
WantedBy=multi-user.target
 systemctl daemon-reload

Включить автозапуск

systemctl enable keenetic-stat

systemctl start keenetic-stat

sudo systemctl status keenetic-stat

Смотреть журнал приложения

sudo journalctl -u keenetic-stat -f

Осталось сделать возможность задавать имена точкам доступа

Создаём таблицу AP в MariaDB:

sudo mariadb radius

CREATE TABLE ap (    id INT UNSIGNED NOT NULL AUTO_INCREMENT,    mac VARCHAR(50) NOT NULL,    name VARCHAR(100) NOT NULL,    ip VARCHAR(15) DEFAULT NULL,    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,    PRIMARY KEY (id),    UNIQUE KEY uq_ap_mac (mac));

Проверка:

DESCRIBE ap;

И:

SELECT * FROM ap;

GRANT SELECT, INSERT, UPDATE, DELETE ON radius.ap TO 'radius_stat'@'localhost';

FLUSH PRIVILEGES;

Пока таблица будет пустая.

Добавим AP из уже имеющихся radacct

INSERT INTO ap (mac, name, ip)

SELECT

calledstationid,

'',

MAX(nasipaddress)

FROM radacct

WHERE calledstationid <> ''

GROUP BY calledstationid

ON DUPLICATE KEY UPDATE

ip = VALUES(ip);

проверка

SELECT id, mac, name, ip FROM ap;

Теперь добавим веб-управление названиями AP в существующий app_stat.py (в моем случае app_stst.py уже был приведен к этому виду в предидущем пункте)

nano /opt/uat/stat/app_stat.py

После функции db_connect() добавить:

@app.route("/stats/ap", methods=["GET", "POST"])

def ap_list():

if request.method == "POST":

ap_id = request.form.get("id", "").strip()

name = request.form.get("name", "").strip()

ip = request.form.get("ip", "").strip()

if ap_id:

with db_connect() as conn:

with conn.cursor() as cursor:

cursor.execute(

"""

UPDATE ap

SET name = %s, ip = %s

WHERE id = %s

""",

(name, ip or None, ap_id)

)

conn.commit()

with db_connect() as conn:

with conn.cursor() as cursor:

cursor.execute("""

SELECT id, mac, name, ip

FROM ap

ORDER BY mac

""")

aps = cursor.fetchall()

return render_template("ap.html", aps=aps)

Создаём страницу

nano /opt/uat/stat/ap.html

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Точки доступа</title><style>
body {
font-family: Arial, sans-serif;
margin: 30px;
background: #f5f5f5;
}

h1 {
margin-bottom: 20px;
}

a {
color: #0066cc;
text-decoration: none;
}

table {
border-collapse: collapse;
width: 100%;
max-width: 900px;
background: white;
}

th, td {
border: 1px solid #ddd;
padding: 10px;
text-align: left;
}

th {
background: #eee;
}

input {
padding: 7px;
width: 90%;
box-sizing: border-box;
}

button {
padding: 7px 15px;
cursor: pointer;
}

.top {
margin-bottom: 20px;
}
</style>
</head>

<body>

<h1>Точки доступа</h1>

<div class="top">
<a href="/stats">← Статистика</a>
</div>

<table>
<thead>
<tr>
<th>MAC</th>
<th>Название</th>
<th>IP</th>
<th>Действие</th>
</tr>
</thead>

<tbody>
{% for ap in aps %}
<tr>
<form method="post">
<td>
{{ ap.mac }}
<input type="hidden" name="id" value="{{ ap.id }}">
</td>

<td>
<input
type="text"
name="name"
value="{{ ap.name }}"
placeholder="Название AP">
</td>

<td>
<input
type="text"
name="ip"
value="{{ ap.ip or '' }}"
placeholder="IP">
</td>

<td>
<button type="submit">Сохранить</button>
</td>
</form>
</tr>
{% endfor %}
</tbody>
</table>

</body>
</html>