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:
746
web/app.py
Normal file
746
web/app.py
Normal 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
119
web/auth_manager.py
Normal 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
0
web/data/.gitkeep
Normal file
232
web/db.py
Normal file
232
web/db.py
Normal 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
1
web/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
Flask>=2.3.0
|
||||
11
web/run.sh
Normal file
11
web/run.sh
Normal 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
420
web/static/app.js
Normal 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 + ' (0–31)' : 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 30–60s)';
|
||||
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
68
web/static/login.html
Normal 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
443
web/static/style.css
Normal 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
39
web/templates/base.html
Normal 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
12
web/templates/config.html
Normal 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 %}
|
||||
32
web/templates/firewall.html
Normal file
32
web/templates/firewall.html
Normal 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
68
web/templates/login.html
Normal 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
10
web/templates/logs.html
Normal 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 %}
|
||||
8
web/templates/restart.html
Normal file
8
web/templates/restart.html
Normal 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
35
web/templates/routes.html
Normal 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
22
web/templates/status.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Status – Alpine 5G Router{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Modem & 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
17
web/templates/users.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user