Add web GUI, docs, scripts, and 5G router config

- Web app (Flask): status, config, firewall, logs, users, restart
- Docs: AT commands, deploy, DNS, quickstart, web GUI
- Scripts: connect, deploy, diag, healthcheck, modem-status, speedtest, status, troubleshoot
- Init and iptables: 5g-router, 5g-webgui, rules.v4
- CHANGELOG, TODO, REVISION; config and README updates
This commit is contained in:
nearxos
2026-02-02 09:38:23 +02:00
parent 1136a332b5
commit 160ad641ce
46 changed files with 4320 additions and 40 deletions

746
web/app.py Normal file
View File

@@ -0,0 +1,746 @@
"""
Alpine 5G Router Web GUI
Login: admin/support with different permissions. Run: python app.py or gunicorn.
Rev: 1 (see REVISION in repo root)
"""
import json
import os
import subprocess
from pathlib import Path
from flask import (
Flask,
jsonify,
redirect,
render_template,
request,
send_from_directory,
session,
url_for,
)
from auth_manager import (
ROLES,
add_user,
can_access_config,
can_access_firewall,
can_manage_users,
can_restart_5g,
can_view_logs,
can_view_status,
delete_user,
get_user,
init_default_users,
list_users,
set_password,
verify_user,
)
from db import (
init_db,
iptables_add,
iptables_delete,
iptables_list,
iptables_update,
routes_add,
routes_delete,
routes_list,
routes_update,
)
app = Flask(__name__, static_folder="static", static_url_path="", template_folder="templates")
app.secret_key = os.environ.get("SECRET_KEY", "change-me-in-production-alpine-5g")
app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 # 512KB max for config/log uploads
CONFIG_PATH = "/etc/5g-router.conf"
IPTABLES_RULES = "/etc/iptables/rules.v4"
LOG_5G = "/var/log/5g-router.log"
LOG_SPEEDTEST = "/var/log/speedtest-5g.log"
CONNECT_SCRIPT = "/usr/local/bin/connect-5g.sh"
STATUS_SCRIPT = "/usr/local/bin/status-5g.sh"
MODEM_STATUS_SCRIPT = "/usr/local/bin/modem-status-at.sh"
SPEEDTEST_SCRIPT = "speedtest-cli"
def _current_user():
if not session.get("username"):
return None
return get_user(session["username"])
def _require_login(f):
from functools import wraps
@wraps(f)
def inner(*args, **kwargs):
if not _current_user():
if request.path.startswith("/api/"):
return jsonify({"error": "Unauthorized"}), 401
return redirect(url_for("login_page"))
return f(*args, **kwargs)
return inner
def _require_role(*allowed_roles):
def decorator(f):
from functools import wraps
@wraps(f)
def inner(*args, **kwargs):
u = _current_user()
if not u:
return jsonify({"error": "Unauthorized"}), 401
if u.get("role") not in allowed_roles:
return jsonify({"error": "Forbidden"}), 403
return f(*args, **kwargs)
return inner
return decorator
# ---- Pages (one HTML per function) ----
@app.route("/")
def index():
if _current_user():
return redirect(url_for("status_page"))
return redirect(url_for("login_page"))
@app.route("/login")
def login_page():
if _current_user():
return redirect(url_for("status_page"))
return render_template("login.html")
@app.route("/logout")
def logout_page():
session.clear()
return redirect(url_for("login_page"))
def _render_page(template, page_id, admin_only=False):
user = _current_user()
if not user:
return redirect(url_for("login_page"))
if admin_only and user.get("role") != "admin":
return redirect(url_for("status_page")) # or 403
return render_template(template, user=user, page_id=page_id)
@app.route("/status")
@_require_login
def status_page():
return _render_page("status.html", "status")
@app.route("/logs")
@_require_login
def logs_page():
return _render_page("logs.html", "logs")
@app.route("/restart")
@_require_login
def restart_page():
return _render_page("restart.html", "restart")
@app.route("/config")
@_require_login
def config_page():
return _render_page("config.html", "config", admin_only=True)
@app.route("/firewall")
@_require_login
def firewall_page():
return _render_page("firewall.html", "firewall", admin_only=True)
@app.route("/routes")
@_require_login
def routes_page():
return _render_page("routes.html", "routes", admin_only=True)
@app.route("/users")
@_require_login
def users_page():
return _render_page("users.html", "users", admin_only=True)
@app.route("/static/<path:path>")
def static_files(path):
return send_from_directory("static", path)
# ---- API: Auth ----
@app.route("/api/login", methods=["POST"])
def api_login():
data = request.get_json() or {}
username = (data.get("username") or "").strip()
password = data.get("password") or ""
if not username or not password:
return jsonify({"error": "Username and password required"}), 400
user = verify_user(username, password)
if not user:
return jsonify({"error": "Invalid username or password"}), 401
session["username"] = user["username"]
session["role"] = user["role"]
return jsonify({"user": user})
@app.route("/api/logout", methods=["POST"])
def api_logout():
session.clear()
return jsonify({"ok": True})
@app.route("/api/me")
@_require_login
def api_me():
u = _current_user()
return jsonify({"user": u})
# ---- API: Status (support + admin) ----
@app.route("/api/status")
@_require_login
def api_status():
if not can_view_status(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
try:
out = subprocess.run(
[STATUS_SCRIPT, "--json"],
capture_output=True,
text=True,
timeout=10,
)
if out.returncode != 0:
return jsonify({"error": "status script failed", "stderr": out.stderr}), 500
data = json.loads(out.stdout)
# Enrich with modem AT status (AT_COMMANDS_REFERENCE.md)
if Path(MODEM_STATUS_SCRIPT).exists():
try:
mod_out = subprocess.run(
[MODEM_STATUS_SCRIPT],
capture_output=True,
text=True,
timeout=15,
cwd="/",
)
if mod_out.returncode == 0 and mod_out.stdout.strip():
mod_data = json.loads(mod_out.stdout)
data["modem"] = mod_data
except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired):
data["modem"] = {}
else:
data["modem"] = {}
return jsonify(data)
except subprocess.TimeoutExpired:
return jsonify({"error": "timeout"}), 504
except Exception as e:
return jsonify({"error": str(e)}), 500
# ---- API: Config (admin only) ----
def _read_config():
if not Path(CONFIG_PATH).exists():
return {}
text = Path(CONFIG_PATH).read_text()
# Simple key="value" or KEY="value" parsing
cfg = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
k, _, v = line.partition("=")
k = k.strip()
v = v.strip().strip('"').strip("'")
cfg[k] = v
return cfg
def _write_config(cfg):
lines = []
for k, v in cfg.items():
if " " in str(v) or '"' in str(v):
v = json.dumps(str(v))
else:
v = f'"{v}"'
lines.append(f'{k}={v}')
Path(CONFIG_PATH).write_text("\n".join(lines) + "\n")
@app.route("/api/config", methods=["GET"])
@_require_login
def api_config_get():
if not can_access_config(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
return jsonify(_read_config())
@app.route("/api/config", methods=["PUT"])
@_require_login
def api_config_put():
if not can_access_config(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
data = request.get_json()
if not data:
return jsonify({"error": "JSON body required"}), 400
try:
_write_config(data)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ---- API: Logs (support + admin) ----
@app.route("/api/logs")
@_require_login
def api_logs():
if not can_view_logs(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
which = request.args.get("which", "5g") # 5g | speedtest
path = LOG_5G if which == "5g" else LOG_SPEEDTEST
tail = int(request.args.get("tail", 200))
if tail > 2000:
tail = 2000
if not Path(path).exists():
return jsonify({"lines": [], "file": path})
try:
lines = subprocess.run(
["tail", "-n", str(tail), path],
capture_output=True,
text=True,
timeout=5,
)
return jsonify({"lines": (lines.stdout or "").strip().split("\n"), "file": path})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ---- API: Firewall (admin only, SQLite) ----
def _iptables_generate_from_db():
"""Generate iptables-restore file content from DB rules."""
rules = iptables_list()
by_table = {}
for r in rules:
if not r.get("enabled"):
continue
t = r.get("table_name") or "filter"
if t not in by_table:
by_table[t] = []
by_table[t].append(r.get("rule_line") or "")
lines = []
for table in ("filter", "nat"):
lines.append("*" + table)
if table == "filter":
lines.append(":INPUT ACCEPT [0:0]")
lines.append(":FORWARD ACCEPT [0:0]")
lines.append(":OUTPUT ACCEPT [0:0]")
lines.append("# Allow web GUI (port 5000) from eth0")
lines.append("-A INPUT -i eth0 -p tcp --dport 5000 -j ACCEPT")
else:
lines.append(":PREROUTING ACCEPT [0:0]")
lines.append(":INPUT ACCEPT [0:0]")
lines.append(":OUTPUT ACCEPT [0:0]")
lines.append(":POSTROUTING ACCEPT [0:0]")
for rule_line in by_table.get(table, []):
if rule_line.strip():
lines.append(rule_line.strip())
lines.append("COMMIT")
return "\n".join(lines) + "\n"
def _seed_firewall_from_file_if_empty():
"""One-time: if DB has no rules and rules.v4 exists, import into DB."""
if iptables_list():
return
if not Path(IPTABLES_RULES).exists():
return
try:
content = Path(IPTABLES_RULES).read_text()
table = None
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("*"):
table = line[1:].strip()
continue
if line == "COMMIT":
table = None
continue
if table and line.startswith("-A "):
iptables_add(table, line, enabled=1, order_idx=0)
except Exception:
pass
@app.route("/api/firewall", methods=["GET"])
@_require_login
def api_firewall_get():
if not can_access_firewall(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
_seed_firewall_from_file_if_empty()
rules = iptables_list()
# Normalize for JSON (sqlite3.Row -> dict with int for id)
out = []
for r in rules:
out.append({
"id": r["id"],
"table_name": r["table_name"],
"rule_line": r["rule_line"],
"enabled": bool(r["enabled"]),
"order_idx": r["order_idx"],
"created_at": r["created_at"] or "",
})
return jsonify({"rules": out, "path": IPTABLES_RULES})
@app.route("/api/firewall", methods=["POST"])
@_require_login
def api_firewall_post():
if not can_access_firewall(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
data = request.get_json() or {}
table_name = (data.get("table_name") or "filter").strip()
rule_line = (data.get("rule_line") or "").strip()
if not rule_line:
return jsonify({"error": "rule_line required"}), 400
order_idx = int(data.get("order_idx") or 0)
try:
rid = iptables_add(table_name, rule_line, enabled=1, order_idx=order_idx)
return jsonify({"ok": True, "id": rid})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/firewall/<int:rule_id>", methods=["PUT"])
@_require_login
def api_firewall_put_id(rule_id):
if not can_access_firewall(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
data = request.get_json() or {}
iptables_update(
rule_id,
table_name=data.get("table_name"),
rule_line=data.get("rule_line"),
enabled=data.get("enabled") if "enabled" in data else None,
order_idx=data.get("order_idx") if "order_idx" in data else None,
)
return jsonify({"ok": True})
@app.route("/api/firewall/<int:rule_id>", methods=["DELETE"])
@_require_login
def api_firewall_delete_id(rule_id):
if not can_access_firewall(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
iptables_delete(rule_id)
return jsonify({"ok": True})
@app.route("/api/firewall/apply", methods=["POST"])
@_require_login
def api_firewall_apply():
if not can_access_firewall(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
try:
content = _iptables_generate_from_db()
Path(IPTABLES_RULES).parent.mkdir(parents=True, exist_ok=True)
Path(IPTABLES_RULES).write_text(content)
subprocess.run(["iptables-restore", IPTABLES_RULES], check=True, timeout=10, capture_output=True, text=True)
return jsonify({"ok": True, "message": "Rules applied"})
except subprocess.CalledProcessError as e:
return jsonify({"error": "iptables-restore failed: " + (e.stderr or str(e))}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
# ---- API: 5G restart (support + admin) ----
@app.route("/api/5g/restart", methods=["POST"])
@_require_login
def api_5g_restart():
if not can_restart_5g(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
try:
subprocess.Popen(
[CONNECT_SCRIPT],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return jsonify({"ok": True, "message": "Restart initiated"})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ---- API: Speedtest (support + admin) ----
def _read_5g_router_config():
"""Read WAN_IF and FAILOVER_IF from /etc/5g-router.conf. Defaults: eth1, eth0."""
cfg = {}
if Path(CONFIG_PATH).exists():
for line in Path(CONFIG_PATH).read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
cfg[k.strip()] = v.strip().strip('"').strip("'")
return {
"wan_if": (cfg.get("WAN_IF") or "eth1").strip(),
"failover_if": (cfg.get("FAILOVER_IF") or "eth0").strip(),
}
def _get_interface_ip(iface):
"""Get first IPv4 address of interface."""
try:
out = subprocess.run(
["ip", "-4", "addr", "show", iface],
capture_output=True,
text=True,
timeout=5,
)
if out.returncode != 0:
return None
import re
m = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", out.stdout)
return m.group(1) if m else None
except Exception:
return None
def _get_public_ip(iface):
"""Get public IPv4 as seen when using this interface (curl --interface)."""
try:
out = subprocess.run(
["curl", "-s", "--max-time", "10", "--interface", iface, "https://api.ipify.org"],
capture_output=True,
text=True,
timeout=12,
)
if out.returncode == 0 and out.stdout and out.stdout.strip():
return out.stdout.strip()
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return None
@app.route("/api/speedtest", methods=["POST"])
@_require_login
def api_speedtest():
if not can_view_status(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
data = request.get_json() or {}
interface = (data.get("interface") or "5g").strip().lower() # 5g | wan
net_cfg = _read_5g_router_config()
# 5g = modem WAN (WAN_IF), wan = failover/management (FAILOVER_IF)
if interface == "5g":
iface = net_cfg["wan_if"]
else:
iface = net_cfg["failover_if"]
source_ip = _get_interface_ip(iface)
# For 5G we must bind to the modem interface; otherwise traffic uses default route (WAN)
if interface == "5g" and not source_ip:
return jsonify({
"error": f"5G interface ({iface}) has no IP. Connect 5G first, then run speedtest.",
}), 503
args = [SPEEDTEST_SCRIPT, "--simple"]
if source_ip:
args.extend(["--source", source_ip])
try:
out = subprocess.run(
args,
capture_output=True,
text=True,
timeout=120,
)
raw = (out.stdout or "").strip()
err = (out.stderr or "").strip()
if out.returncode != 0:
return jsonify({"error": err or "speedtest failed", "raw": raw}), 500
# Public IP as seen from this interface
public_ip = _get_public_ip(iface)
result = {
"raw": raw,
"interface": interface,
"iface": iface,
"source_ip": source_ip,
"public_ip": public_ip,
}
for line in raw.split("\n"):
line = line.strip()
if line.startswith("Ping:"):
result["ping"] = line.replace("Ping:", "").strip()
elif line.startswith("Download:"):
result["download"] = line.replace("Download:", "").strip()
elif line.startswith("Upload:"):
result["upload"] = line.replace("Upload:", "").strip()
return jsonify(result)
except subprocess.TimeoutExpired:
return jsonify({"error": "speedtest timeout (120s)"}), 504
except FileNotFoundError:
return jsonify({"error": "speedtest-cli not installed (apk add speedtest-cli)"}), 503
except Exception as e:
return jsonify({"error": str(e)}), 500
# ---- API: Routes (admin only, SQLite) ----
@app.route("/api/routes", methods=["GET"])
@_require_login
def api_routes_get():
if not can_access_firewall(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
rows = routes_list()
out = []
for r in rows:
out.append({
"id": r["id"],
"destination": r["destination"] or "",
"gateway": r["gateway"] or "",
"dev": r["dev"] or "",
"metric": r["metric"],
"enabled": bool(r["enabled"]),
"created_at": r["created_at"] or "",
})
return jsonify({"routes": out})
@app.route("/api/routes/live", methods=["GET"])
@_require_login
def api_routes_live():
if not can_access_firewall(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
try:
out = subprocess.run(["ip", "route", "show"], capture_output=True, text=True, timeout=5)
lines = (out.stdout or "").strip().split("\n")
return jsonify({"routes": lines})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/routes", methods=["POST"])
@_require_login
def api_routes_post():
if not can_access_firewall(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
data = request.get_json() or {}
destination = (data.get("destination") or "").strip()
if not destination:
return jsonify({"error": "destination required"}), 400
gateway = (data.get("gateway") or "").strip() or None
dev = (data.get("dev") or "").strip() or None
metric = data.get("metric")
if metric is not None:
metric = int(metric)
try:
rid = routes_add(destination, gateway=gateway, dev=dev, metric=metric)
return jsonify({"ok": True, "id": rid})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/routes/<int:route_id>", methods=["PUT"])
@_require_login
def api_routes_put_id(route_id):
if not can_access_firewall(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
data = request.get_json() or {}
kwargs = {}
if "destination" in data:
kwargs["destination"] = data["destination"]
if "gateway" in data:
kwargs["gateway"] = data["gateway"]
if "dev" in data:
kwargs["dev"] = data["dev"]
if "metric" in data:
kwargs["metric"] = int(data["metric"]) if data["metric"] is not None else None
if "enabled" in data:
kwargs["enabled"] = data["enabled"]
routes_update(route_id, **kwargs)
return jsonify({"ok": True})
@app.route("/api/routes/<int:route_id>", methods=["DELETE"])
@_require_login
def api_routes_delete_id(route_id):
if not can_access_firewall(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
routes_delete(route_id)
return jsonify({"ok": True})
@app.route("/api/routes/apply", methods=["POST"])
@_require_login
def api_routes_apply():
if not can_access_firewall(_current_user().get("role")):
return jsonify({"error": "Forbidden"}), 403
try:
rows = [r for r in routes_list() if r.get("enabled")]
for r in rows:
dest = (r.get("destination") or "").strip()
if not dest:
continue
args = ["ip", "route", "add", dest]
if (r.get("gateway") or "").strip():
args.extend(["via", (r.get("gateway") or "").strip()])
if (r.get("dev") or "").strip():
args.extend(["dev", (r.get("dev") or "").strip()])
if r.get("metric") is not None:
args.extend(["metric", str(int(r["metric"]))])
subprocess.run(args, timeout=5, capture_output=True, text=True)
return jsonify({"ok": True, "message": "Routes applied"})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ---- API: Users (admin only) ----
@app.route("/api/users", methods=["GET"])
@_require_login
@_require_role("admin")
def api_users_list():
return jsonify({"users": list_users(), "roles": list(ROLES)})
@app.route("/api/users", methods=["PUT"])
@_require_login
@_require_role("admin")
def api_users_put():
data = request.get_json()
if not data:
return jsonify({"error": "JSON required"}), 400
action = data.get("action") # add | password | delete
if action == "add":
ok, err = add_user(
data.get("username", "").strip(),
data.get("password", ""),
data.get("role", "support"),
)
if not ok:
return jsonify({"error": err or "Failed"}), 400
return jsonify({"ok": True})
if action == "password":
username = data.get("username", "").strip()
password = data.get("password", "")
if not username or not password:
return jsonify({"error": "username and password required"}), 400
if get_user(username) is None:
return jsonify({"error": "User not found"}), 404
set_password(username, password)
return jsonify({"ok": True})
if action == "delete":
username = data.get("username", "").strip()
if not username:
return jsonify({"error": "username required"}), 400
ok, err = delete_user(username)
if not ok:
return jsonify({"error": err or "Failed"}), 400
return jsonify({"ok": True})
return jsonify({"error": "Invalid action"}), 400
# ---- Entry (default users created on first request or at import) ----
init_default_users()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)

119
web/auth_manager.py Normal file
View File

@@ -0,0 +1,119 @@
"""User storage and auth for Alpine 5G Web GUI. Uses SQLite (db.py)."""
import json
from pathlib import Path
from werkzeug.security import check_password_hash, generate_password_hash
from db import (
init_db,
user_add as db_user_add,
user_delete as db_user_delete,
user_get as db_user_get,
user_list as db_user_list,
user_set_password as db_user_set_password,
user_verify as db_user_verify,
)
DATA_DIR = Path(__file__).resolve().parent / "data"
USERS_FILE = DATA_DIR / "users.json"
ROLES = ("admin", "support")
def can_access_config(role):
return role == "admin"
def can_access_firewall(role):
return role == "admin"
def can_access_routes(role):
return role == "admin"
def can_manage_users(role):
return role == "admin"
def can_restart_5g(role):
return role in ("admin", "support")
def can_view_logs(role):
return role in ("admin", "support")
def can_view_status(role):
return role in ("admin", "support")
def _migrate_users_from_json():
"""One-time: copy users from users.json into SQLite if DB has no users."""
if not USERS_FILE.exists():
return
try:
data = json.loads(USERS_FILE.read_text())
users = data.get("users", [])
except (json.JSONDecodeError, IOError):
return
for u in users:
username = u.get("username")
password_hash = u.get("password_hash")
role = u.get("role", "support")
if not username or not password_hash:
continue
existing = db_user_get(username)
if not existing:
db_user_add(username, password_hash, role)
# Optionally rename so we don't migrate again
try:
USERS_FILE.rename(USERS_FILE.with_suffix(".json.bak"))
except OSError:
pass
def init_default_users():
"""Ensure DB exists and has at least default admin/support if empty."""
init_db()
if db_user_list():
return
_migrate_users_from_json()
if db_user_list():
return
db_user_add("admin", generate_password_hash("admin"), "admin")
db_user_add("support", generate_password_hash("support"), "support")
def verify_user(username: str, password: str):
def check(ph):
return check_password_hash(ph, password)
return db_user_verify(username, check)
def get_user(username: str):
return db_user_get(username)
def list_users():
return db_user_list()
def set_password(username: str, new_password: str):
return db_user_set_password(username, generate_password_hash(new_password))
def add_user(username: str, password: str, role: str):
if role not in ROLES:
return False, "Invalid role"
ok, err = db_user_add(username, generate_password_hash(password), role)
return ok, err
def delete_user(username: str):
if username == "admin":
return False, "Cannot delete admin"
if not db_user_get(username):
return False, "User not found"
db_user_delete(username)
return True, None

0
web/data/.gitkeep Normal file
View File

232
web/db.py Normal file
View File

@@ -0,0 +1,232 @@
"""
Alpine 5G Web GUI SQLite database.
Tables: users, iptables_rules, static_routes.
"""
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime
DATA_DIR = Path(__file__).resolve().parent / "data"
DB_PATH = DATA_DIR / "alpine5g.db"
def _ensure_data_dir():
DATA_DIR.mkdir(parents=True, exist_ok=True)
def get_conn():
_ensure_data_dir()
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
@contextmanager
def cursor():
conn = get_conn()
try:
cur = conn.cursor()
yield cur
conn.commit()
finally:
conn.close()
def init_db():
"""Create tables if they don't exist."""
_ensure_data_dir()
with cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'support')),
created_at TEXT NOT NULL DEFAULT ''
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS iptables_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL DEFAULT 'filter',
rule_line TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
order_idx INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT ''
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS static_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination TEXT NOT NULL,
gateway TEXT,
dev TEXT,
metric INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT ''
)
""")
# ---- Users ----
def user_get(username):
with cursor() as cur:
cur.execute("SELECT username, role FROM users WHERE username = ?", (username,))
row = cur.fetchone()
return dict(row) if row else None
def user_verify(username, password_hash_check_fn):
with cursor() as cur:
cur.execute("SELECT username, password_hash, role FROM users WHERE username = ?", (username,))
row = cur.fetchone()
if not row:
return None
if not password_hash_check_fn(row["password_hash"]):
return None
return {"username": row["username"], "role": row["role"]}
def user_list():
with cursor() as cur:
cur.execute("SELECT username, role FROM users ORDER BY username")
return [dict(r) for r in cur.fetchall()]
def user_add(username, password_hash, role):
try:
with cursor() as cur:
cur.execute(
"INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)",
(username, password_hash, role, datetime.utcnow().isoformat()),
)
return True, None
except sqlite3.IntegrityError as e:
if "UNIQUE" in str(e):
return False, "User exists"
return False, str(e)
def user_set_password(username, password_hash):
with cursor() as cur:
cur.execute("UPDATE users SET password_hash = ? WHERE username = ?", (password_hash, username))
return cur.rowcount > 0
def user_delete(username):
with cursor() as cur:
cur.execute("DELETE FROM users WHERE username = ?", (username,))
return cur.rowcount > 0
# ---- iptables_rules ----
def iptables_list():
with cursor() as cur:
cur.execute(
"SELECT id, table_name, rule_line, enabled, order_idx, created_at FROM iptables_rules ORDER BY table_name, order_idx, id"
)
return [dict(r) for r in cur.fetchall()]
def iptables_add(table_name, rule_line, enabled=1, order_idx=0):
with cursor() as cur:
cur.execute(
"INSERT INTO iptables_rules (table_name, rule_line, enabled, order_idx, created_at) VALUES (?, ?, ?, ?, ?)",
(table_name, rule_line, 1 if enabled else 0, order_idx, datetime.utcnow().isoformat()),
)
return cur.lastrowid
def iptables_update(rule_id, table_name=None, rule_line=None, enabled=None, order_idx=None):
with cursor() as cur:
updates = []
args = []
if table_name is not None:
updates.append("table_name = ?")
args.append(table_name)
if rule_line is not None:
updates.append("rule_line = ?")
args.append(rule_line)
if enabled is not None:
updates.append("enabled = ?")
args.append(1 if enabled else 0)
if order_idx is not None:
updates.append("order_idx = ?")
args.append(order_idx)
if not updates:
return False
args.append(rule_id)
cur.execute("UPDATE iptables_rules SET " + ", ".join(updates) + " WHERE id = ?", args)
return cur.rowcount > 0
def iptables_delete(rule_id):
with cursor() as cur:
cur.execute("DELETE FROM iptables_rules WHERE id = ?", (rule_id,))
return cur.rowcount > 0
def iptables_get(rule_id):
with cursor() as cur:
cur.execute("SELECT id, table_name, rule_line, enabled, order_idx, created_at FROM iptables_rules WHERE id = ?", (rule_id,))
row = cur.fetchone()
return dict(row) if row else None
# ---- static_routes ----
def routes_list():
with cursor() as cur:
cur.execute(
"SELECT id, destination, gateway, dev, metric, enabled, created_at FROM static_routes ORDER BY id"
)
return [dict(r) for r in cur.fetchall()]
def routes_add(destination, gateway=None, dev=None, metric=None, enabled=1):
with cursor() as cur:
cur.execute(
"INSERT INTO static_routes (destination, gateway, dev, metric, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(destination, gateway or "", dev or "", metric, 1 if enabled else 0, datetime.utcnow().isoformat()),
)
return cur.lastrowid
def routes_update(route_id, destination=None, gateway=None, dev=None, metric=None, enabled=None):
with cursor() as cur:
updates = []
args = []
if destination is not None:
updates.append("destination = ?")
args.append(destination)
if gateway is not None:
updates.append("gateway = ?")
args.append(gateway)
if dev is not None:
updates.append("dev = ?")
args.append(dev)
if metric is not None:
updates.append("metric = ?")
args.append(metric)
if enabled is not None:
updates.append("enabled = ?")
args.append(1 if enabled else 0)
if not updates:
return False
args.append(route_id)
cur.execute("UPDATE static_routes SET " + ", ".join(updates) + " WHERE id = ?", args)
return cur.rowcount > 0
def routes_delete(route_id):
with cursor() as cur:
cur.execute("DELETE FROM static_routes WHERE id = ?", (route_id,))
return cur.rowcount > 0
def routes_get(route_id):
with cursor() as cur:
cur.execute("SELECT id, destination, gateway, dev, metric, enabled, created_at FROM static_routes WHERE id = ?", (route_id,))
row = cur.fetchone()
return dict(row) if row else None

1
web/requirements.txt Normal file
View File

@@ -0,0 +1 @@
Flask>=2.3.0

11
web/run.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
# Run Alpine 5G Web GUI (Flask). Use from install dir, e.g. /usr/local/share/5g-webgui.
cd "$(dirname "$0")"
export FLASK_APP=app.py
export PYTHONUNBUFFERED=1
exec python3 -c "
from app import app
from auth_manager import init_default_users
init_default_users()
app.run(host='0.0.0.0', port=5000, threaded=True)
"

420
web/static/app.js Normal file
View File

@@ -0,0 +1,420 @@
(function () {
// Alpine 5G Router Rev: 2 (see REVISION in repo root)
const API = '/api';
const pageId = document.body.getAttribute('data-page');
if (!pageId) return;
async function api(path, opts = {}) {
const res = await fetch(API + path, {
...opts,
headers: {
'Content-Type': 'application/json',
...(opts.headers || {}),
},
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (res.status === 401) {
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
function showMsg(elId, text, type) {
const el = document.getElementById(elId);
if (!el) return;
el.textContent = text;
el.className = 'msg visible ' + (type || '');
el.classList.remove('success', 'error');
if (type) el.classList.add(type);
if (text) setTimeout(() => { el.classList.remove('visible'); }, 4000);
}
function escapeHtml(s) {
if (s == null) return '';
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
// ---- Status page ----
if (pageId === 'status') {
const grid = document.getElementById('statusGrid');
const modemGrid = document.getElementById('modemGrid');
const refreshBtn = document.getElementById('refreshStatus');
const speedtest5gBtn = document.getElementById('speedtest5gBtn');
const speedtestWanBtn = document.getElementById('speedtestWanBtn');
const speedtestResult = document.getElementById('speedtestResult');
const speedtestMsg = document.getElementById('speedtestMsg');
if (!grid) return;
function statusItem(label, value) {
return '<div class="status-item"><label>' + escapeHtml(label) + '</label><div class="value">' + escapeHtml(value == null || value === '' ? '' : String(value)) + '</div></div>';
}
async function loadStatus() {
grid.innerHTML = '<span style="color: var(--text-muted);">Loading…</span>';
if (modemGrid) modemGrid.innerHTML = '';
try {
const s = await api('/status');
grid.innerHTML = [
{ label: 'Modem USB', value: s.modem_usb || '' },
{ label: 'WAN interface', value: s.wan_interface || '' },
{ label: 'WAN state', value: s.wan_state || '' },
{ label: 'WAN IP', value: s.wan_ip || '' },
{ label: 'Default route', value: s.default_route || '' },
{ label: 'AT port', value: (s.at_port || '') + ' (' + (s.at_available === 'yes' ? 'available' : 'no') + ')' },
].map(function (o) { return statusItem(o.label, o.value); }).join('');
if (modemGrid && s.modem && Object.keys(s.modem).length) {
const m = s.modem;
modemGrid.innerHTML = [
{ label: 'AT port', value: m.at_port },
{ label: 'Manufacturer', value: m.manufacturer },
{ label: 'Model', value: m.model },
{ label: 'Revision', value: m.revision },
{ label: 'IMEI', value: m.imei },
{ label: 'Signal (CSQ)', value: m.signal_csq != null ? m.signal_csq + ' (031)' : null },
{ label: '2G/3G registration', value: m.creg_status },
{ label: 'LTE/5G registration', value: m.cereg_status },
{ label: 'USB mode', value: m.usb_mode ? m.usb_mode + ' (40=RNDIS)' : null },
{ label: 'ICCID (SIM)', value: m.iccid },
{ label: 'Operator', value: m.operator },
{ label: 'Modem IP', value: m.modem_ip },
].map(function (o) { return statusItem(o.label, o.value); }).join('');
} else if (modemGrid) {
modemGrid.innerHTML = '<span style="color: var(--text-muted);">No modem AT data (check AT port).</span>';
}
} catch (e) {
grid.innerHTML = '<span class="msg error visible">' + escapeHtml(e.message) + '</span>';
if (modemGrid) modemGrid.innerHTML = '';
}
}
async function runSpeedtest(iface) {
if (!speedtestResult) return;
speedtestResult.textContent = 'Running speedtest… (may take 3060s)';
if (speedtestMsg) { speedtestMsg.textContent = ''; speedtestMsg.classList.remove('visible', 'success', 'error'); }
if (speedtest5gBtn) speedtest5gBtn.disabled = true;
if (speedtestWanBtn) speedtestWanBtn.disabled = true;
try {
const data = await api('/speedtest', { method: 'POST', body: JSON.stringify({ interface: iface }) });
const ifLabel = data.interface === '5g' ? '5G (modem)' : 'WAN';
const ifDev = data.iface ? ` (${data.iface})` : '';
let out = ifLabel + ifDev + '\n';
if (data.source_ip) out += 'Local IP: ' + data.source_ip + '\n';
if (data.public_ip) out += 'Public IP: ' + data.public_ip + '\n';
else if (data.interface) out += 'Public IP: (unknown)\n';
out += '\n';
if (data.ping) out += 'Ping: ' + data.ping + '\n';
if (data.download) out += 'Download: ' + data.download + '\n';
if (data.upload) out += 'Upload: ' + data.upload + '\n';
if (data.raw) out += '\n' + data.raw;
speedtestResult.textContent = out;
if (speedtestMsg) { showMsg('speedtestMsg', 'Speedtest done.', 'success'); }
} catch (e) {
speedtestResult.textContent = 'Error: ' + e.message;
if (speedtestMsg) showMsg('speedtestMsg', e.message, 'error');
} finally {
if (speedtest5gBtn) speedtest5gBtn.disabled = false;
if (speedtestWanBtn) speedtestWanBtn.disabled = false;
}
}
if (speedtest5gBtn) speedtest5gBtn.addEventListener('click', function () { runSpeedtest('5g'); });
if (speedtestWanBtn) speedtestWanBtn.addEventListener('click', function () { runSpeedtest('wan'); });
loadStatus();
if (refreshBtn) refreshBtn.addEventListener('click', function () { loadStatus(); });
}
// ---- Logs page ----
if (pageId === 'logs') {
const view = document.getElementById('logView');
const buttons = document.querySelectorAll('button[data-log]');
if (!view) return;
async function loadLog(which) {
view.textContent = 'Loading…';
try {
const data = await api('/logs?which=' + (which === 'speedtest' ? 'speedtest' : '5g') + '&tail=300');
view.textContent = data.lines && data.lines.length ? data.lines.join('\n') : '(empty)';
} catch (e) {
view.textContent = 'Error: ' + e.message;
}
}
buttons.forEach(btn => btn.addEventListener('click', () => loadLog(btn.getAttribute('data-log'))));
loadLog('5g');
}
// ---- Restart page ----
if (pageId === 'restart') {
const btn = document.getElementById('restart5gBtn');
if (!btn) return;
btn.addEventListener('click', async () => {
btn.disabled = true;
showMsg('restartMsg', '', '');
try {
await api('/5g/restart', { method: 'POST' });
showMsg('restartMsg', 'Restart initiated.', 'success');
} catch (e) {
showMsg('restartMsg', e.message, 'error');
} finally {
btn.disabled = false;
}
});
}
// ---- Config page ----
if (pageId === 'config') {
const editor = document.getElementById('configEditor');
const loadBtn = document.getElementById('loadConfig');
const saveBtn = document.getElementById('saveConfig');
if (!editor) return;
async function loadConfig() {
try {
const cfg = await api('/config');
editor.value = Object.entries(cfg).map(([k, v]) => k + '="' + (v || '') + '"').join('\n');
} catch (e) {
showMsg('configMsg', e.message, 'error');
}
}
saveBtn.addEventListener('click', async () => {
const raw = editor.value;
const cfg = {};
raw.split('\n').forEach(line => {
line = line.trim();
if (!line || line.startsWith('#')) return;
const eq = line.indexOf('=');
if (eq > 0) {
const k = line.slice(0, eq).trim();
let v = line.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
cfg[k] = v;
}
});
try {
await api('/config', { method: 'PUT', body: JSON.stringify(cfg) });
showMsg('configMsg', 'Saved.', 'success');
} catch (e) {
showMsg('configMsg', e.message, 'error');
}
});
loadConfig();
if (loadBtn) loadBtn.addEventListener('click', loadConfig);
}
// ---- Firewall page (SQLite) ----
if (pageId === 'firewall') {
const tableBody = document.getElementById('firewallTable');
const tableNameEl = document.getElementById('firewallTableName');
const ruleLineEl = document.getElementById('firewallRuleLine');
const addBtn = document.getElementById('firewallAddBtn');
const applyBtn = document.getElementById('firewallApplyBtn');
if (!tableBody) return;
async function loadFirewall() {
tableBody.innerHTML = '<tr><td colspan="4">Loading…</td></tr>';
try {
const data = await api('/firewall');
const rules = data.rules || [];
if (rules.length === 0) {
tableBody.innerHTML = '<tr><td colspan="4">No rules. Add one below.</td></tr>';
return;
}
tableBody.innerHTML = rules.map(r => {
const delBtn = '<button type="button" class="btn btn-danger" data-delete-id="' + r.id + '">Delete</button>';
return '<tr><td>' + escapeHtml(r.table_name) + '</td><td class="value">' + escapeHtml(r.rule_line) + '</td><td>' + (r.enabled ? 'yes' : 'no') + '</td><td>' + delBtn + '</td></tr>';
}).join('');
tableBody.querySelectorAll('[data-delete-id]').forEach(btn => {
btn.addEventListener('click', () => deleteRule(parseInt(btn.getAttribute('data-delete-id'), 10)));
});
} catch (e) {
tableBody.innerHTML = '<tr><td colspan="4" class="error">' + escapeHtml(e.message) + '</td></tr>';
}
}
async function deleteRule(id) {
if (!confirm('Delete this rule?')) return;
try {
await api('/firewall/' + id, { method: 'DELETE' });
showMsg('firewallMsg', 'Rule deleted.', 'success');
loadFirewall();
} catch (e) {
showMsg('firewallMsg', e.message, 'error');
}
}
addBtn.addEventListener('click', async () => {
const table_name = (tableNameEl && tableNameEl.value) || 'filter';
const rule_line = (ruleLineEl && ruleLineEl.value) || '';
if (!rule_line.trim()) { showMsg('firewallMsg', 'Rule line required.', 'error'); return; }
try {
await api('/firewall', { method: 'POST', body: JSON.stringify({ table_name, rule_line }) });
showMsg('firewallMsg', 'Rule added.', 'success');
if (ruleLineEl) ruleLineEl.value = '';
loadFirewall();
} catch (e) {
showMsg('firewallMsg', e.message, 'error');
}
});
applyBtn.addEventListener('click', async () => {
try {
await api('/firewall/apply', { method: 'POST' });
showMsg('firewallMsg', 'Rules applied (iptables-restore).', 'success');
} catch (e) {
showMsg('firewallMsg', e.message, 'error');
}
});
loadFirewall();
}
// ---- Routes page (SQLite) ----
if (pageId === 'routes') {
const tableBody = document.getElementById('routesTable');
const liveEl = document.getElementById('routesLive');
const destEl = document.getElementById('routeDest');
const gwEl = document.getElementById('routeGw');
const devEl = document.getElementById('routeDev');
const metricEl = document.getElementById('routeMetric');
const addBtn = document.getElementById('routeAddBtn');
const applyBtn = document.getElementById('routesApplyBtn');
const refreshLiveBtn = document.getElementById('routesRefreshLive');
if (!tableBody) return;
async function loadRoutes() {
tableBody.innerHTML = '<tr><td colspan="6">Loading…</td></tr>';
try {
const data = await api('/routes');
const routes = data.routes || [];
if (routes.length === 0) {
tableBody.innerHTML = '<tr><td colspan="6">No routes. Add one below.</td></tr>';
return;
}
tableBody.innerHTML = routes.map(r => {
const delBtn = '<button type="button" class="btn btn-danger" data-delete-id="' + r.id + '">Delete</button>';
return '<tr><td>' + escapeHtml(r.destination) + '</td><td>' + escapeHtml(r.gateway) + '</td><td>' + escapeHtml(r.dev) + '</td><td>' + (r.metric != null ? r.metric : '') + '</td><td>' + (r.enabled ? 'yes' : 'no') + '</td><td>' + delBtn + '</td></tr>';
}).join('');
tableBody.querySelectorAll('[data-delete-id]').forEach(btn => {
btn.addEventListener('click', () => deleteRoute(parseInt(btn.getAttribute('data-delete-id'), 10)));
});
} catch (e) {
tableBody.innerHTML = '<tr><td colspan="6" class="error">' + escapeHtml(e.message) + '</td></tr>';
}
}
async function loadLive() {
if (!liveEl) return;
liveEl.innerHTML = 'Loading…';
try {
const data = await api('/routes/live');
const lines = data.routes || [];
liveEl.innerHTML = lines.length ? lines.map(l => '<div>' + escapeHtml(l) + '</div>').join('') : '(none)';
} catch (e) {
liveEl.innerHTML = 'Error: ' + escapeHtml(e.message);
}
}
async function deleteRoute(id) {
if (!confirm('Delete this route?')) return;
try {
await api('/routes/' + id, { method: 'DELETE' });
showMsg('routesMsg', 'Route deleted.', 'success');
loadRoutes();
} catch (e) {
showMsg('routesMsg', e.message, 'error');
}
}
addBtn.addEventListener('click', async () => {
const destination = (destEl && destEl.value) || '';
const gateway = (gwEl && gwEl.value) || '';
const dev = (devEl && devEl.value) || '';
const metric = metricEl && metricEl.value !== '' ? parseInt(metricEl.value, 10) : undefined;
if (!destination.trim()) { showMsg('routesMsg', 'Destination required.', 'error'); return; }
try {
await api('/routes', { method: 'POST', body: JSON.stringify({ destination: destination.trim(), gateway: gateway.trim() || undefined, dev: dev.trim() || undefined, metric }) });
showMsg('routesMsg', 'Route added.', 'success');
if (destEl) destEl.value = '';
if (gwEl) gwEl.value = '';
if (devEl) devEl.value = '';
if (metricEl) metricEl.value = '';
loadRoutes();
} catch (e) {
showMsg('routesMsg', e.message, 'error');
}
});
applyBtn.addEventListener('click', async () => {
try {
await api('/routes/apply', { method: 'POST' });
showMsg('routesMsg', 'Routes applied (ip route add).', 'success');
loadLive();
} catch (e) {
showMsg('routesMsg', e.message, 'error');
}
});
if (refreshLiveBtn) refreshLiveBtn.addEventListener('click', loadLive);
loadRoutes();
loadLive();
}
// ---- Users page ----
if (pageId === 'users') {
const tbody = document.getElementById('usersTable');
const addBtn = document.getElementById('addUserBtn');
if (!tbody) return;
async function loadUsers() {
try {
const data = await api('/users');
tbody.innerHTML = data.users.map(u => {
const del = u.username !== 'admin' ? '<button type="button" class="btn btn-danger" data-delete="' + escapeHtml(u.username) + '">Delete</button>' : '';
return '<tr><td>' + escapeHtml(u.username) + '</td><td>' + escapeHtml(u.role) + '</td><td>' + del + '</td></tr>';
}).join('');
tbody.querySelectorAll('[data-delete]').forEach(btn => {
btn.addEventListener('click', () => deleteUser(btn.getAttribute('data-delete')));
});
} catch (e) {
showMsg('usersMsg', e.message, 'error');
}
}
async function deleteUser(username) {
if (!confirm('Delete user "' + username + '"?')) return;
try {
await api('/users', { method: 'PUT', body: JSON.stringify({ action: 'delete', username }) });
showMsg('usersMsg', 'User deleted.', 'success');
loadUsers();
} catch (e) {
showMsg('usersMsg', e.message, 'error');
}
}
addBtn.addEventListener('click', () => {
const username = prompt('Username:');
if (!username) return;
const password = prompt('Password:');
if (!password) return;
const role = (prompt('Role (admin or support):', 'support') || 'support').toLowerCase();
if (role !== 'admin' && role !== 'support') {
showMsg('usersMsg', 'Role must be admin or support.', 'error');
return;
}
api('/users', { method: 'PUT', body: JSON.stringify({ action: 'add', username: username.trim(), password, role }) })
.then(() => { showMsg('usersMsg', 'User added.', 'success'); loadUsers(); })
.catch(e => showMsg('usersMsg', e.message, 'error'));
});
loadUsers();
}
})();

68
web/static/login.html Normal file
View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login Alpine 5G Router</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="login-wrap">
<div class="login-card">
<h1>Alpine 5G Router</h1>
<p class="sub">Sign in to manage modem and network</p>
<form id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<div id="loginError" class="login-error"></div>
<button type="submit" class="btn btn-primary" id="submitBtn">Sign in</button>
</form>
</div>
</div>
<script>
const form = document.getElementById('loginForm');
const errorEl = document.getElementById('loginError');
const submitBtn = document.getElementById('submitBtn');
function showError(msg) {
errorEl.textContent = msg || 'Login failed';
errorEl.classList.add('visible');
}
function hideError() {
errorEl.textContent = '';
errorEl.classList.remove('visible');
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
submitBtn.disabled = true;
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
window.location.href = '/dashboard';
return;
}
showError(data.error || 'Invalid username or password');
} catch (err) {
showError('Network error');
} finally {
submitBtn.disabled = false;
}
});
</script>
</body>
</html>

443
web/static/style.css Normal file
View File

@@ -0,0 +1,443 @@
/* Alpine 5G Web GUI shared styles */
:root {
--bg: #0f1419;
--surface: #1a2332;
--border: #2d3a4d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--accent-hover: #79b8ff;
--danger: #f85149;
--success: #3fb950;
--radius: 8px;
--font: system-ui, -apple-system, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Login */
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
width: 100%;
max-width: 360px;
}
.login-card h1 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
}
.login-card .sub {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
margin-bottom: 0.35rem;
color: var(--text-muted);
}
.form-group input {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
font-size: 0.9375rem;
font-weight: 500;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary {
background: var(--accent);
color: #fff;
width: 100%;
padding: 0.6rem;
margin-top: 0.5rem;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: var(--border);
color: var(--text);
}
.btn-secondary:hover {
background: #3d4d66;
}
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-danger:hover {
background: #ff6b6b;
}
.login-error {
margin-top: 1rem;
padding: 0.5rem 0.75rem;
background: rgba(248, 81, 73, 0.15);
border: 1px solid var(--danger);
border-radius: 6px;
color: var(--danger);
font-size: 0.875rem;
display: none;
}
.login-error.visible {
display: block;
}
/* Dashboard */
.dash {
max-width: 1000px;
margin: 0 auto;
padding: 1rem;
}
.dash-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.dash-header h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.user-badge {
font-size: 0.875rem;
color: var(--text-muted);
}
.user-badge strong {
color: var(--text);
}
.nav-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.nav-tabs button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
}
.nav-tabs button:hover {
color: var(--text);
border-color: var(--text-muted);
}
.nav-tabs button.active {
background: var(--surface);
color: var(--accent);
border-color: var(--accent);
}
/* Nav links (per-page layout) */
.nav-links {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.nav-link {
padding: 0.5rem 1rem;
font-size: 0.875rem;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
background: transparent;
text-decoration: none;
}
.nav-link:hover {
color: var(--text);
border-color: var(--text-muted);
}
.nav-link.active {
background: var(--surface);
color: var(--accent);
border-color: var(--accent);
}
.page-content {
margin-top: 0.5rem;
}
.panel {
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1rem;
}
.panel.active {
display: block;
}
.panel h2 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-muted);
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.status-item {
padding: 0.75rem;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
}
.status-item label {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.25rem;
}
.status-item .value {
font-family: ui-monospace, monospace;
font-size: 0.875rem;
word-break: break-all;
}
.log-view {
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
max-height: 400px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.config-editor,
.firewall-editor {
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
width: 100%;
min-height: 280px;
padding: 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
resize: vertical;
}
.config-editor:focus,
.firewall-editor:focus {
outline: none;
border-color: var(--accent);
}
.config-form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.config-form .form-group {
margin-bottom: 0;
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.msg {
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
margin-top: 0.5rem;
display: none;
}
.msg.visible {
display: block;
}
.msg.success {
background: rgba(63, 185, 80, 0.15);
border: 1px solid var(--success);
color: var(--success);
}
.msg.error {
background: rgba(248, 81, 73, 0.15);
border: 1px solid var(--danger);
color: var(--danger);
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th, td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
color: var(--text-muted);
font-weight: 500;
}
.routes-list {
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
max-height: 300px;
overflow: auto;
}
.routes-list div {
padding: 0.25rem 0;
border-bottom: 1px solid var(--border);
}
.routes-list div:last-child {
border-bottom: none;
}
.hidden {
display: none !important;
}
.form-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.form-row input,
.form-row select {
padding: 0.4rem 0.5rem;
font-size: 0.875rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.form-row input:focus,
.form-row select:focus {
outline: none;
border-color: var(--accent);
}
.form-row input[type="number"] {
width: 5rem;
}
.status-section {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-muted);
margin: 1.25rem 0 0.5rem;
}
.speedtest-result {
font-family: ui-monospace, monospace;
font-size: 0.875rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem;
min-height: 2rem;
white-space: pre-wrap;
}

39
web/templates/base.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Alpine 5G Router{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="page-{{ page_id }}" data-page="{{ page_id }}">
<div class="dash">
<header class="dash-header">
<h1><a href="{{ url_for('status_page') }}" style="color: inherit; text-decoration: none;">Alpine 5G Router</a></h1>
<div class="user-badge">
<span id="userName">{{ user.username if user else '' }}</span>
<span id="userRole">{% if user %}({{ user.role }}){% endif %}</span>
<a href="{{ url_for('logout_page') }}" class="btn btn-secondary" style="margin-left: 0.75rem; text-decoration: none;">Log out</a>
</div>
</header>
<nav class="nav-links">
<a href="{{ url_for('status_page') }}" class="nav-link {% if page_id == 'status' %}active{% endif %}">Status</a>
<a href="{{ url_for('logs_page') }}" class="nav-link {% if page_id == 'logs' %}active{% endif %}">Logs</a>
<a href="{{ url_for('restart_page') }}" class="nav-link {% if page_id == 'restart' %}active{% endif %}">Restart 5G</a>
{% if user and user.role == 'admin' %}
<a href="{{ url_for('config_page') }}" class="nav-link {% if page_id == 'config' %}active{% endif %}">Config</a>
<a href="{{ url_for('firewall_page') }}" class="nav-link {% if page_id == 'firewall' %}active{% endif %}">Firewall</a>
<a href="{{ url_for('routes_page') }}" class="nav-link {% if page_id == 'routes' %}active{% endif %}">Routes</a>
<a href="{{ url_for('users_page') }}" class="nav-link {% if page_id == 'users' %}active{% endif %}">Users</a>
{% endif %}
</nav>
<main class="page-content">
{% block content %}{% endblock %}
</main>
</div>
<script src="{{ url_for('static', filename='app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

12
web/templates/config.html Normal file
View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Config Alpine 5G Router{% endblock %}
{% block content %}
<h2>Router config (/etc/5g-router.conf)</h2>
<p style="color: var(--text-muted); margin: 0 0 1rem;">Edit and save. Keys and values only (one per line).</p>
<textarea id="configEditor" class="config-editor" spellcheck="false"></textarea>
<div class="actions">
<button type="button" class="btn btn-primary" id="saveConfig">Save config</button>
<button type="button" class="btn btn-secondary" id="loadConfig">Reload</button>
</div>
<div id="configMsg" class="msg"></div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Firewall Alpine 5G Router{% endblock %}
{% block content %}
<h2>Firewall rules (iptables)</h2>
<p style="color: var(--text-muted); margin: 0 0 1rem;">Rules are stored in SQLite. Add rules below, then click Apply to write /etc/iptables/rules.v4 and run iptables-restore.</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>Table</th><th>Rule</th><th>Enabled</th><th>Actions</th></tr>
</thead>
<tbody id="firewallTable"></tbody>
</table>
</div>
<div class="panel-form" style="margin-top: 1rem;">
<h3 style="font-size: 0.9375rem; margin: 0 0 0.5rem;">Add rule</h3>
<div class="form-row">
<select id="firewallTableName">
<option value="filter">filter</option>
<option value="nat">nat</option>
</select>
<input type="text" id="firewallRuleLine" placeholder="-A FORWARD -i eth0.100 -o eth1 -j ACCEPT" style="flex: 1; min-width: 200px;">
<button type="button" class="btn btn-primary" id="firewallAddBtn">Add</button>
</div>
</div>
<div class="actions" style="margin-top: 1rem;">
<button type="button" class="btn btn-primary" id="firewallApplyBtn">Apply to system</button>
</div>
<div id="firewallMsg" class="msg"></div>
{% endblock %}

68
web/templates/login.html Normal file
View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login Alpine 5G Router</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="login-wrap">
<div class="login-card">
<h1>Alpine 5G Router</h1>
<p class="sub">Sign in to manage modem and network</p>
<form id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<div id="loginError" class="login-error"></div>
<button type="submit" class="btn btn-primary" id="submitBtn">Sign in</button>
</form>
</div>
</div>
<script>
const form = document.getElementById('loginForm');
const errorEl = document.getElementById('loginError');
const submitBtn = document.getElementById('submitBtn');
function showError(msg) {
errorEl.textContent = msg || 'Login failed';
errorEl.classList.add('visible');
}
function hideError() {
errorEl.textContent = '';
errorEl.classList.remove('visible');
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
submitBtn.disabled = true;
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
window.location.href = '{{ url_for("status_page") }}';
return;
}
showError(data.error || 'Invalid username or password');
} catch (err) {
showError('Network error');
} finally {
submitBtn.disabled = false;
}
});
</script>
</body>
</html>

10
web/templates/logs.html Normal file
View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Logs Alpine 5G Router{% endblock %}
{% block content %}
<h2>Logs</h2>
<div class="actions" style="margin-bottom: 0.5rem;">
<button type="button" class="btn btn-secondary" data-log="5g">5G router log</button>
<button type="button" class="btn btn-secondary" data-log="speedtest">Speedtest log</button>
</div>
<div class="log-view" id="logView">Select a log above.</div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}Restart 5G Alpine 5G Router{% endblock %}
{% block content %}
<h2>Restart 5G connection</h2>
<p style="color: var(--text-muted); margin: 0 0 1rem;">Run the connection script to bring up or refresh the 5G link.</p>
<button type="button" class="btn btn-primary" id="restart5gBtn">Restart 5G</button>
<div id="restartMsg" class="msg"></div>
{% endblock %}

35
web/templates/routes.html Normal file
View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Routes Alpine 5G Router{% endblock %}
{% block content %}
<h2>Static routes (SQLite)</h2>
<p style="color: var(--text-muted); margin: 0 0 1rem;">Routes are stored in SQLite. Add below, then click Apply to run <code>ip route add</code> for each. Existing system routes are not removed.</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>Destination</th><th>Gateway</th><th>Dev</th><th>Metric</th><th>Enabled</th><th>Actions</th></tr>
</thead>
<tbody id="routesTable"></tbody>
</table>
</div>
<div class="panel-form" style="margin-top: 1rem;">
<h3 style="font-size: 0.9375rem; margin: 0 0 0.5rem;">Add route</h3>
<div class="form-row">
<input type="text" id="routeDest" placeholder="0.0.0.0/0" style="width: 120px;">
<input type="text" id="routeGw" placeholder="gateway (optional)" style="width: 100px;">
<input type="text" id="routeDev" placeholder="dev (e.g. eth1)" style="width: 80px;">
<input type="number" id="routeMetric" placeholder="metric" style="width: 70px;">
<button type="button" class="btn btn-primary" id="routeAddBtn">Add</button>
</div>
</div>
<div class="actions" style="margin-top: 1rem;">
<button type="button" class="btn btn-primary" id="routesApplyBtn">Apply to system</button>
<button type="button" class="btn btn-secondary" id="routesRefreshLive">Refresh live view</button>
</div>
<div id="routesMsg" class="msg"></div>
<h3 style="font-size: 0.9375rem; margin: 1.5rem 0 0.5rem;">Current system routes (ip route show)</h3>
<div class="routes-list" id="routesLive"></div>
{% endblock %}

22
web/templates/status.html Normal file
View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Status Alpine 5G Router{% endblock %}
{% block content %}
<h2>Modem &amp; network status</h2>
<div class="status-grid" id="statusGrid"></div>
<h3 class="status-section">Modem details (AT)</h3>
<div class="status-grid" id="modemGrid"></div>
<h3 class="status-section">Speedtest</h3>
<p style="color: var(--text-muted); margin: 0 0 0.5rem;">Run speedtest via 5G (modem) or WAN (eth0).</p>
<div class="actions" style="margin-bottom: 0.5rem;">
<button type="button" class="btn btn-primary" id="speedtest5gBtn">Speedtest (5G)</button>
<button type="button" class="btn btn-secondary" id="speedtestWanBtn">Speedtest (WAN)</button>
</div>
<div id="speedtestResult" class="speedtest-result"></div>
<div id="speedtestMsg" class="msg"></div>
<div class="actions" style="margin-top: 1rem;">
<button type="button" class="btn btn-secondary" id="refreshStatus">Refresh</button>
</div>
{% endblock %}

17
web/templates/users.html Normal file
View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Users Alpine 5G Router{% endblock %}
{% block content %}
<h2>Users (admin only)</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>Username</th><th>Role</th><th>Actions</th></tr>
</thead>
<tbody id="usersTable"></tbody>
</table>
</div>
<div class="actions" style="margin-top: 1rem;">
<button type="button" class="btn btn-primary" id="addUserBtn">Add user</button>
</div>
<div id="usersMsg" class="msg"></div>
{% endblock %}