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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,3 +14,8 @@ Thumbs.db
|
|||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
|
|
||||||
|
# Web GUI – sensitive / generated
|
||||||
|
web/data/users.json
|
||||||
|
web/data/*.db
|
||||||
|
web/data/*.db-journal
|
||||||
|
|||||||
@@ -97,10 +97,13 @@ echo -e 'AT+CFUN=1,1\r' > /dev/ttyUSB1
|
|||||||
|
|
||||||
### Why DHCP Doesn't Work
|
### Why DHCP Doesn't Work
|
||||||
|
|
||||||
The RNDIS interface (`eth1`) does **not** provide DHCP. The modem manages the connection internally. You must:
|
The RNDIS interface (`eth1`) does **not** provide DHCP. The modem does not assign an IP to the host via DHCP. You must:
|
||||||
|
|
||||||
1. Get the IP from the modem via `AT+CGPADDR=1`
|
1. Get the IP from the modem via **`AT+CGPADDR=1`**
|
||||||
2. Configure the interface manually
|
2. Optionally get DNS via **`AT+CGCONTRDP=1`** (connection dynamic parameters)
|
||||||
|
3. Configure the interface and `/etc/resolv.conf` manually
|
||||||
|
|
||||||
|
The **connect-5g.sh** script does exactly this: it uses AT commands only (no DHCP on eth1), then sets `ip addr` and default route on eth1, and DNS from modem or from config.
|
||||||
|
|
||||||
### Manual Configuration
|
### Manual Configuration
|
||||||
|
|
||||||
@@ -169,16 +172,68 @@ ping -c 3 8.8.8.8
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### AT Commands Not Responding
|
### Run full diagnostic (modem not responding after reboot)
|
||||||
|
|
||||||
**Symptoms:** No response to AT commands on any port.
|
**When the modem does not respond** after a reboot, run the full troubleshoot script on the device to collect all logs and checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the device (SSH or console)
|
||||||
|
/usr/local/bin/troubleshoot-5g.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If the script is not installed yet, deploy first (`./scripts/deploy.sh` or `./scripts/install.sh` on the device), or run from the repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/troubleshoot-5g.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script prints:
|
||||||
|
|
||||||
|
- **dmesg** (last 40 lines) – kernel messages about USB/tty
|
||||||
|
- **lsusb** – modem present and Mode 40 (7126) vs Mode 41 (7127)
|
||||||
|
- **/dev/ttyUSB*** – whether each port is a character device or a broken regular file
|
||||||
|
- **AT test** on the configured port (with 3s wait, like connect-5g.sh)
|
||||||
|
- **AT probe** on each ttyUSB port – which port returns OK
|
||||||
|
- **5g-router service** status
|
||||||
|
- **WAN interface** and default route
|
||||||
|
- **Last 60 lines** of `/var/log/5g-router.log`
|
||||||
|
- **modem-status-at.sh** output (registration, signal) if installed
|
||||||
|
|
||||||
|
Copy the full output and use it to see: wrong AT port, broken ttyUSB node, modem in Mode 41, or service/APN issues. Then apply the fixes listed at the end of the script or in the sections below.
|
||||||
|
|
||||||
|
### AT Commands Not Responding / "AT not OK"
|
||||||
|
|
||||||
|
**Symptoms:** No response to AT commands, or connect script logs "AT not OK".
|
||||||
|
|
||||||
**Solutions:**
|
**Solutions:**
|
||||||
1. Check modem is in Mode 40: `lsusb | grep 7126`
|
1. **Wait longer** – The modem can take 2–4 seconds to respond. The script now waits 3 s for the initial AT. If you still see "AT not OK", increase the wait in `get_at_response` or in config.
|
||||||
2. If in Mode 41 (7127), AT commands won't work - need physical access to switch back
|
2. Check modem is in Mode 40: `lsusb | grep 7126`
|
||||||
3. Try different ports: ttyUSB0, ttyUSB1, ttyUSB2
|
3. If in Mode 41 (7127), AT commands won't work - need physical access to switch back
|
||||||
|
4. **Try different ports** – On FM350-GL the AT port is often ttyUSB1, but it can be ttyUSB0 or another. Test manually:
|
||||||
|
```bash
|
||||||
|
( timeout 5 cat /dev/ttyUSB1 & ); sleep 0.5; echo -e 'AT\r' > /dev/ttyUSB1; sleep 3; kill %1 2>/dev/null
|
||||||
|
```
|
||||||
|
If you see `OK` in the output, that port works. Set `AT_PORT="/dev/ttyUSB1"` (or the working port) in `/etc/5g-router.conf`.
|
||||||
|
5. Ensure no other process is holding the port (e.g. ModemManager, or a stuck cat). Stop ModemManager if present: `rc-service ModemManager stop`
|
||||||
|
|
||||||
### ttyUSB3 Shows as Regular File
|
### ttyUSB port shows as regular file (AT not responding)
|
||||||
|
|
||||||
|
**Symptoms:** `ls -la /dev/ttyUSB1` shows `-rw-rw----` (regular file) instead of `crw-rw----` (character device). AT commands get no reply or garbage.
|
||||||
|
|
||||||
|
**Cause:** Sometimes after modem disconnect/reconnect (or a script writing to the port when it was missing), a regular file is created at `/dev/ttyUSB1` (or another number). The kernel then attaches the real device to a different name or the file blocks the node.
|
||||||
|
|
||||||
|
**Fix (one-time):**
|
||||||
|
```bash
|
||||||
|
# Replace N with the port number (0, 1, 2, …)
|
||||||
|
rm -f /dev/ttyUSB1
|
||||||
|
mknod /dev/ttyUSB1 c 188 1
|
||||||
|
chmod 660 /dev/ttyUSB1
|
||||||
|
chown root:dialout /dev/ttyUSB1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prevention:** `connect-5g.sh` now checks and fixes a broken AT port automatically before use (recreates the device node if it is a regular file).
|
||||||
|
|
||||||
|
### ttyUSB3 Shows as Regular File (legacy)
|
||||||
|
|
||||||
**Symptoms:** `ls -la /dev/ttyUSB3` shows `-rw-r--r--` instead of `crw-rw----`
|
**Symptoms:** `ls -la /dev/ttyUSB3` shows `-rw-r--r--` instead of `crw-rw----`
|
||||||
|
|
||||||
@@ -196,11 +251,15 @@ chown root:dialout /dev/ttyUSB3
|
|||||||
|
|
||||||
**Solution:** Don't use `stty`. Send AT commands directly with `echo` and `cat`.
|
**Solution:** Don't use `stty`. Send AT commands directly with `echo` and `cat`.
|
||||||
|
|
||||||
### No IP on eth1
|
### No IP on eth1 / Could not get modem IP
|
||||||
|
|
||||||
**Symptoms:** `ip addr show eth1` shows no inet address after DHCP attempt.
|
**Symptoms:** `ip addr show eth1` shows no inet address; or connect script logs "Could not get modem IP".
|
||||||
|
|
||||||
**Solution:** RNDIS doesn't use DHCP. Configure IP manually from `AT+CGPADDR=1`.
|
**Solutions:**
|
||||||
|
1. RNDIS doesn't use DHCP. The script gets the IP from `AT+CGPADDR=1`; if the operator hasn't assigned one yet, it retries a few times. Wait and re-run the connection script.
|
||||||
|
2. Check registration and signal: run `AT+CEREG?` (expect `,1` or `,5` for registered) and `AT+CSQ` (signal strength). If not registered or no signal, fix antenna/SIM/location.
|
||||||
|
3. Ensure APN is correct for your operator (e.g. `internet` for CYTA).
|
||||||
|
4. Try activating PDP again: `AT+CGACT=1,1` then wait 5–10 s and `AT+CGPADDR=1`.
|
||||||
|
|
||||||
### ModemManager Not Detecting Modem
|
### ModemManager Not Detecting Modem
|
||||||
|
|
||||||
@@ -237,6 +296,7 @@ lsmod | grep -E '(cdc|qmi)'
|
|||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `/usr/local/bin/connect-5g.sh` | Auto-connection script |
|
| `/usr/local/bin/connect-5g.sh` | Auto-connection script |
|
||||||
|
| `/usr/local/bin/troubleshoot-5g.sh` | Full diagnostic (logs + AT/USB checks) |
|
||||||
| `/etc/init.d/5g-router` | OpenRC service |
|
| `/etc/init.d/5g-router` | OpenRC service |
|
||||||
| `/var/log/5g-router.log` | Connection log |
|
| `/var/log/5g-router.log` | Connection log |
|
||||||
| `/etc/iptables/rules.v4` | Firewall/NAT rules |
|
| `/etc/iptables/rules.v4` | Firewall/NAT rules |
|
||||||
|
|||||||
38
CHANGELOG.md
Normal file
38
CHANGELOG.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the Alpine 5G Router project are documented here.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Config file** – `/etc/5g-router.conf` (and `etc/5g-router.conf.example`) for APN, interfaces, failover, watchdog, signal logging.
|
||||||
|
- **Configurable APN and interfaces** – `configure_fm350_5g.sh` and `connect-5g.sh` read from config; defaults remain CYTA/eth1/eth0.100.
|
||||||
|
- **USB mode check on boot** – Scripts detect Mode 41 (7127), warn and attempt switch to Mode 40 (with modem reset).
|
||||||
|
- **Signal strength logging** – Optional `LOG_SIGNAL=yes` and `WEAK_SIGNAL_CSQ` in config; logged to `/var/log/5g-router.log`.
|
||||||
|
- **Fallback to eth0** – Optional `FAILOVER_ENABLED=yes` and `FAILOVER_IF=eth0` so default route uses ethernet when 5G is down.
|
||||||
|
- **Watchdog** – Optional `WATCHDOG_INTERVAL` in config to re-run connection periodically.
|
||||||
|
- **Firewall rules in repo** – `etc/iptables/rules.v4` for repeatable NAT/forward rules.
|
||||||
|
- **connect-5g.sh in repo** – `scripts/connect-5g.sh` (installed to `/usr/local/bin/connect-5g.sh`).
|
||||||
|
- **OpenRC service in repo** – `etc/init.d/5g-router` for Alpine.
|
||||||
|
- **Install script** – `scripts/install.sh` deploys scripts, service, firewall, and config example to the device.
|
||||||
|
- **DNS documentation** – `docs/DNS.md` for resolv.conf and dnsmasq.
|
||||||
|
- **Status script** – `scripts/status-5g.sh` (modem, interface, default route, last speedtest); supports `--json`.
|
||||||
|
- **Health check script** – `scripts/healthcheck-5g.sh` for Nagios/Uptime Kuma (exit 0/1).
|
||||||
|
- **Log rotation** – `scripts/rotate-5g-log.sh` for `/var/log/5g-router.log` (cron-friendly; Alpine has no logrotate by default).
|
||||||
|
- **Optional speedtest cron** – `scripts/speedtest-5g.sh` appends to `/var/log/speedtest-5g.log`; document cron in README.
|
||||||
|
- **Quick start** – `docs/QUICKSTART.md` (clone → install → config → start).
|
||||||
|
- **Deploy / SSH doc** – `docs/DEPLOY.md` for first-time SSH and key-based login.
|
||||||
|
- **Example config** – `etc/5g-router.conf.example` for different carriers/interfaces.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **configure_fm350_5g.sh** – Sources `/etc/5g-router.conf` if present; USB Mode 41 handling (warn + try switch); signal logging and weak-signal alert when enabled in config.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **Persist default route** – Handled by running `connect-5g.sh` at boot via 5g-router service; failover adds eth0 default when 5G fails.
|
||||||
|
- **Dual-WAN / failover** – Basic failover (use eth0 when 5G down) is implemented; full dual-WAN policy routing is not.
|
||||||
|
- **IPv6** – Not implemented; carrier and kernel support required; can be added later.
|
||||||
|
- **Web GUI** – Implemented with login (admin/support), per-page templates, and SQLite backend.
|
||||||
|
- **SQLite database** – `web/data/alpine5g.db` for users, iptables_rules, and static_routes. Users migrated from `users.json` on first run; firewall rules seeded from `/etc/iptables/rules.v4` when DB is empty. Firewall and Routes GUI: list/add/edit/delete from DB; **Apply** writes iptables and runs `iptables-restore`, or runs `ip route add` for routes.
|
||||||
83
README.md
83
README.md
@@ -10,9 +10,14 @@ The 5G modem is fully operational with CYTA Cyprus SIM card.
|
|||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| [README.md](README.md) | This file - overview and quick start |
|
| [README.md](README.md) | This file – overview and reference |
|
||||||
| [5G_MODEM_TROUBLESHOOTING.md](5G_MODEM_TROUBLESHOOTING.md) | Complete modem configuration reference |
|
| [docs/QUICKSTART.md](docs/QUICKSTART.md) | **Quick start** – clone → install script → config → start |
|
||||||
| [configure_fm350_5g.sh](configure_fm350_5g.sh) | Configuration script for the modem |
|
| [docs/DEPLOY.md](docs/DEPLOY.md) | First-time SSH and key-based deploy |
|
||||||
|
| [docs/DNS.md](docs/DNS.md) | DNS (resolv.conf, dnsmasq) for router and LAN |
|
||||||
|
| [5G_MODEM_TROUBLESHOOTING.md](5G_MODEM_TROUBLESHOOTING.md) | Modem AT commands and troubleshooting |
|
||||||
|
| [configure_fm350_5g.sh](configure_fm350_5g.sh) | Manual configuration script (uses `/etc/5g-router.conf`) |
|
||||||
|
| [CHANGELOG.md](CHANGELOG.md) | Version and feature notes |
|
||||||
|
| [docs/WEBGUI.md](docs/WEBGUI.md) | Web GUI – login (admin/support), permissions, install |
|
||||||
|
|
||||||
## Hardware
|
## Hardware
|
||||||
|
|
||||||
@@ -42,7 +47,21 @@ Internet (CYTA 5G)
|
|||||||
LAN Clients
|
LAN Clients
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start (new device)
|
||||||
|
|
||||||
|
For a **single-command flow** from a fresh device, see **[docs/QUICKSTART.md](docs/QUICKSTART.md)**. Summary:
|
||||||
|
|
||||||
|
1. Clone or copy this repo to the device.
|
||||||
|
2. Install packages: `apk add iptables libmbim-tools qmi-utils` (and optionally dnsmasq, speedtest-cli).
|
||||||
|
3. Run **`./scripts/install.sh`** – installs scripts, OpenRC service, firewall rules, and `/etc/5g-router.conf`.
|
||||||
|
4. Edit `/etc/5g-router.conf` if needed (APN, interfaces).
|
||||||
|
5. Start: `service 5g-router start` or `/usr/local/bin/connect-5g.sh`.
|
||||||
|
|
||||||
|
For SSH and key setup: **[docs/DEPLOY.md](docs/DEPLOY.md)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual Quick Start (step-by-step)
|
||||||
|
|
||||||
### 1. Install Required Packages
|
### 1. Install Required Packages
|
||||||
|
|
||||||
@@ -154,20 +173,35 @@ rc-status
|
|||||||
# - 5g-router (connection script)
|
# - 5g-router (connection script)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Files on Device
|
## Files on Device (after install)
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `/usr/local/bin/connect-5g.sh` | Startup connection script |
|
| `/etc/5g-router.conf` | Config (APN, interfaces, failover, watchdog) – from repo `etc/5g-router.conf.example` |
|
||||||
|
| `/usr/local/bin/connect-5g.sh` | Connection script (run by service or manually) |
|
||||||
|
| `/usr/local/bin/status-5g.sh` | Status (modem, interface, route, last speedtest) |
|
||||||
|
| `/usr/local/bin/healthcheck-5g.sh` | Health check for monitoring (exit 0/1) |
|
||||||
|
| `/usr/local/bin/speedtest-5g.sh` | Optional speedtest cron target |
|
||||||
|
| `/usr/local/bin/rotate-5g-log.sh` | Optional log rotation for 5g-router.log |
|
||||||
| `/etc/init.d/5g-router` | OpenRC service |
|
| `/etc/init.d/5g-router` | OpenRC service |
|
||||||
| `/etc/init.d/iptables-restore` | Firewall restore service |
|
| `/etc/init.d/iptables-restore` | Firewall restore service |
|
||||||
| `/etc/iptables/rules.v4` | Saved firewall rules |
|
| `/etc/iptables/rules.v4` | Saved firewall rules (from repo `etc/iptables/rules.v4`) |
|
||||||
| `/etc/dnsmasq.conf` | DHCP configuration |
|
| `/etc/dnsmasq.conf` | DHCP configuration (if using dnsmasq) |
|
||||||
| `/etc/network/interfaces` | Network configuration |
|
|
||||||
| `/var/log/5g-router.log` | Connection log |
|
| `/var/log/5g-router.log` | Connection log |
|
||||||
|
| `/var/log/speedtest-5g.log` | Optional speedtest log |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Modem not up (WAN down, no IP)
|
||||||
|
|
||||||
|
**On the device**, run the modem/WAN diagnostic:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/usr/local/bin/diag-modem-up.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It reports service status, modem USB (Mode 40 vs 41), WAN interface state, AT port, last log lines, and suggested fixes. See [docs/WEBGUI.md](docs/WEBGUI.md) → Troubleshooting: Modem not up.
|
||||||
|
|
||||||
### Modem not responding to AT commands
|
### Modem not responding to AT commands
|
||||||
|
|
||||||
1. Check modem is in Mode 40: `lsusb | grep 7126`
|
1. Check modem is in Mode 40: `lsusb | grep 7126`
|
||||||
@@ -193,20 +227,31 @@ service 5g-router restart
|
|||||||
## Verification Commands
|
## Verification Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check modem
|
# Status script (modem, interface, default route, last speedtest)
|
||||||
lsusb | grep -i fibocom
|
/usr/local/bin/status-5g.sh
|
||||||
|
/usr/local/bin/status-5g.sh --json
|
||||||
|
|
||||||
# Check network
|
# Diagnostics (run on device to debug modem/WAN or AT port)
|
||||||
|
/usr/local/bin/diag-modem-up.sh # why modem not up
|
||||||
|
/usr/local/bin/diag-at-port.sh # why no AT data in Web GUI
|
||||||
|
|
||||||
|
# Health check (for Nagios / Uptime Kuma)
|
||||||
|
/usr/local/bin/healthcheck-5g.sh
|
||||||
|
|
||||||
|
# Manual checks
|
||||||
|
lsusb | grep -i fibocom
|
||||||
ip addr show eth1
|
ip addr show eth1
|
||||||
ip route show
|
ip route show
|
||||||
|
|
||||||
# Test connectivity
|
|
||||||
ping -c 3 8.8.8.8
|
ping -c 3 8.8.8.8
|
||||||
ping -c 3 google.com
|
|
||||||
|
|
||||||
# Check NAT rules
|
|
||||||
iptables -t nat -L -n -v
|
iptables -t nat -L -n -v
|
||||||
|
|
||||||
# View logs
|
|
||||||
tail -f /var/log/5g-router.log
|
tail -f /var/log/5g-router.log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Optional: scheduled speedtest and log rotation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cron: speedtest every 6 hours, log rotation daily
|
||||||
|
# Add to crontab -e (root):
|
||||||
|
# 0 */6 * * * /usr/local/bin/speedtest-5g.sh
|
||||||
|
# 0 3 * * * /usr/local/bin/rotate-5g-log.sh
|
||||||
|
```
|
||||||
|
|||||||
18
REVISION
Normal file
18
REVISION
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Alpine 5G Router – file revisions
|
||||||
|
# Bump the rev in this file and in the file’s “Rev: N” line when you change a file.
|
||||||
|
# On device, check installed rev: head -3 /usr/local/bin/modem-status-at.sh
|
||||||
|
|
||||||
|
scripts/modem-status-at.sh 2
|
||||||
|
scripts/status-5g.sh 1
|
||||||
|
scripts/connect-5g.sh 2
|
||||||
|
scripts/healthcheck-5g.sh 1
|
||||||
|
scripts/speedtest-5g.sh 1
|
||||||
|
scripts/rotate-5g-log.sh 1
|
||||||
|
scripts/install.sh 1
|
||||||
|
scripts/diag-at-port.sh 1
|
||||||
|
scripts/diag-modem-up.sh 1
|
||||||
|
etc/init.d/5g-router 5
|
||||||
|
etc/init.d/5g-webgui 1
|
||||||
|
configure_fm350_5g.sh 1
|
||||||
|
web/app.py 1
|
||||||
|
web/static/app.js 2
|
||||||
67
TODO.md
Normal file
67
TODO.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Alpine 5G Router – TODO / Features
|
||||||
|
|
||||||
|
Track features and tasks for the solution. Use `[ ]` for pending, `[x]` for done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Connection & modem
|
||||||
|
|
||||||
|
- [x] Auto-reconnect when 5G link drops (watchdog or periodic check)
|
||||||
|
- [x] Configurable APN (env var or config file) for different carriers
|
||||||
|
- [x] USB mode check on boot (warn or fix if modem is in Mode 41)
|
||||||
|
- [x] Signal strength logging / alert when signal is weak
|
||||||
|
- [x] Fallback to eth0 (e.g. LAN) when 5G is unavailable (optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Network & routing
|
||||||
|
|
||||||
|
- [x] Persist default route preference (5G vs eth0) across reboots
|
||||||
|
- [x] Optional dual-WAN or failover (5G + ethernet)
|
||||||
|
- [x] Firewall rules in repo (e.g. `etc/iptables/rules.v4`) for repeatable setup
|
||||||
|
- [ ] IPv6 support (if carrier provides it)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment & automation
|
||||||
|
|
||||||
|
- [x] Add `connect-5g.sh` (or equivalent) to repo for version control
|
||||||
|
- [x] Add OpenRC service file(s) to repo (e.g. `etc/init.d/5g-router`)
|
||||||
|
- [x] Install/setup script: deploy scripts + service + firewall from repo to device
|
||||||
|
- [x] Document or automate DNS (e.g. resolv.conf / dnsmasq) for 5G
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring & ops
|
||||||
|
|
||||||
|
- [x] Simple status script or endpoint (modem, interface, default route, last speedtest)
|
||||||
|
- [x] Optional speedtest on schedule (cron) with log or alert
|
||||||
|
- [x] Log rotation for `5g-router.log` (and any other logs)
|
||||||
|
- [x] Health check script for monitoring (Nagios, Uptime Kuma, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation & config
|
||||||
|
|
||||||
|
- [x] Single "quick start" for new device (clone repo → run script → done)
|
||||||
|
- [x] Document or script for first-time SSH/key setup
|
||||||
|
- [x] Example or template config (APN, interfaces, firewall) for different carriers
|
||||||
|
- [x] Changelog or version notes for script/service changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web GUI
|
||||||
|
|
||||||
|
- [x] Login screen with admin and support users and different permissions
|
||||||
|
- [x] Configuration Modem/network interfaces (admin: /etc/5g-router.conf)
|
||||||
|
- [x] Monitoring status Modem/systems (Status tab)
|
||||||
|
- [ ] Data consumption from both terminals (graphs/tables) – *future: needs traffic accounting e.g. vnstat*
|
||||||
|
- [x] Logs modem/system (5G router log + speedtest log)
|
||||||
|
- [x] List/add/modify firewall rules (admin: iptables rules.v4)
|
||||||
|
- [x] List/modify static routes (admin: ip route view)
|
||||||
|
- [x] User management (admin: add/delete users, set password)
|
||||||
|
- [x] Restart 5G (admin + support)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Edit this file to add/remove items and check off completed work.*
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Fibocom FM350-GL 5G Modem Configuration Script
|
# Fibocom FM350-GL 5G Modem Configuration Script
|
||||||
# For Alpine Linux with CYTA Cyprus SIM card
|
# For Alpine Linux. Supports config file /etc/5g-router.conf
|
||||||
#
|
|
||||||
# This script configures the 5G modem and establishes a connection
|
|
||||||
# Usage: ./configure_fm350_5g.sh
|
# Usage: ./configure_fm350_5g.sh
|
||||||
|
# Rev: 1 (see REVISION in repo root)
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Configuration
|
# Defaults (overridden by /etc/5g-router.conf if present)
|
||||||
AT_PORT="/dev/ttyUSB1"
|
AT_PORT="${AT_PORT:-/dev/ttyUSB1}"
|
||||||
APN="internet"
|
APN="${APN:-internet}"
|
||||||
WAN_IF="eth1"
|
WAN_IF="${WAN_IF:-eth1}"
|
||||||
LAN_IF="eth0.100"
|
LAN_IF="${LAN_IF:-eth0.100}"
|
||||||
|
LOG_SIGNAL="${LOG_SIGNAL:-no}"
|
||||||
|
WEAK_SIGNAL_CSQ="${WEAK_SIGNAL_CSQ:-0}"
|
||||||
|
[ -f /etc/5g-router.conf ] && . /etc/5g-router.conf
|
||||||
|
LOG_FILE="${LOG_FILE:-/var/log/5g-router.log}"
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -62,7 +65,11 @@ check_modem() {
|
|||||||
|
|
||||||
if [ "$usb_id" = "0e8d:7127" ]; then
|
if [ "$usb_id" = "0e8d:7127" ]; then
|
||||||
log_warn "Modem is in Mode 41 (7127). AT commands may not work."
|
log_warn "Modem is in Mode 41 (7127). AT commands may not work."
|
||||||
log_warn "Attempting to switch to Mode 40..."
|
log_warn "Attempting to switch to Mode 40 (modem will reset)..."
|
||||||
|
get_at_response "AT+GTUSBMODE=40" 2 >/dev/null || true
|
||||||
|
get_at_response "AT+CFUN=1,1" 5 >/dev/null || true
|
||||||
|
log_info "Wait ~30s for modem to reappear, then re-run this script."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -c "$AT_PORT" ]; then
|
if [ ! -c "$AT_PORT" ]; then
|
||||||
@@ -101,6 +108,11 @@ check_signal() {
|
|||||||
else
|
else
|
||||||
log_info "Signal strength: $csq (good)"
|
log_info "Signal strength: $csq (good)"
|
||||||
fi
|
fi
|
||||||
|
[ "$LOG_SIGNAL" = "yes" ] && echo "$(date -Iseconds) CSQ=$csq" >> "$LOG_FILE" 2>/dev/null || true
|
||||||
|
if [ -n "$WEAK_SIGNAL_CSQ" ] && [ "$WEAK_SIGNAL_CSQ" -gt 0 ] && [ "$csq" -lt "$WEAK_SIGNAL_CSQ" ]; then
|
||||||
|
log_warn "Signal below threshold ($WEAK_SIGNAL_CSQ): $csq"
|
||||||
|
[ -w "$LOG_FILE" ] && echo "$(date -Iseconds) WARN weak signal CSQ=$csq" >> "$LOG_FILE" || true
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
log_warn "Could not read signal strength"
|
log_warn "Could not read signal strength"
|
||||||
fi
|
fi
|
||||||
|
|||||||
311
docs/AT_COMMANDS_REFERENCE.md
Normal file
311
docs/AT_COMMANDS_REFERENCE.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
# Fibocom FM350-GL AT Commands Reference
|
||||||
|
|
||||||
|
Complete list of AT commands from **Fibocom FM350 AT Commands User Manual V2.10** (2023).
|
||||||
|
Source: `Fibocom_FM350_AT Commands User Manual_V2.10.pdf` in this repo.
|
||||||
|
|
||||||
|
**Tip:** To get the modem’s own list at runtime, use **`AT+CLAC`** (List of All Available AT Commands).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sending AT commands (FM350-GL)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /dev/ttyUSB1 & CAT_PID=$!
|
||||||
|
sleep 0.3
|
||||||
|
echo -e 'YOUR_COMMAND\r' > /dev/ttyUSB1
|
||||||
|
sleep 2
|
||||||
|
kill $CAT_PID 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `/dev/ttyUSB1` when modem is in **USB Mode 40** (0e8d:7126). In Mode 41, AT commands do not work.
|
||||||
|
|
||||||
|
**If AT commands get no response:** run **`/usr/local/bin/diag-at-port.sh`** on the device to check serial ports, permissions, and which port responds to `AT`. See WEBGUI.md → Troubleshooting: No modem AT data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Basic / test
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT` | Test communication (expect `OK`) |
|
||||||
|
| `ATE0` / `ATE1` | Disable / enable command echo |
|
||||||
|
| `A/` | Repeat last command |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Modem information (Ch. 3)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+CGMI` / `AT+GMI` | Manufacturer ID |
|
||||||
|
| `AT+CGMM` / `AT+GMM` | Model ID |
|
||||||
|
| `AT+CGMR` / `AT+GMR` | Revision / firmware version |
|
||||||
|
| `AT+CGSN` / `AT+GSN` | IMEI (product serial number) |
|
||||||
|
| `AT+CIMI` | IMSI |
|
||||||
|
| `AT+CNUM` | MSISDN(s) (phone number) |
|
||||||
|
| `AT+CCID` / `AT+ICCID` | SIM ICCID |
|
||||||
|
| `AT+EID` | eSIM EID |
|
||||||
|
| **`AT+CLAC`** | **List of all available AT commands** |
|
||||||
|
| `AT+SIMTYPE` | Current SIM type |
|
||||||
|
| `AT+CFSN` | Factory serial number |
|
||||||
|
| `AT+GTGATR` | Answer to Reset (ATR) info |
|
||||||
|
| `AT+GTAPPVER` | sAP firmware version |
|
||||||
|
| `AT+GTBASELINEVER` | Baseline version |
|
||||||
|
| `AT+GTPKGVER` | Firmware package version |
|
||||||
|
| `AT+GTMCFWVER` | Firmware original version |
|
||||||
|
| `AT+GTCUSTPACKVER` | Operator image version |
|
||||||
|
| `AT+GTCFGELEMVER` | Customized image version |
|
||||||
|
| `AT+GTCUSTDATAVER` | Device parameters image version |
|
||||||
|
| `AT+GTCURCAR` | Current carrier ID and name |
|
||||||
|
| `AT+GTLOCKCAR` | Carrier lock mode |
|
||||||
|
| `AT+GTESIMCFG` | eSIM function |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Modem control (Ch. 4)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+CFUN` | Phone functionality (e.g. reset: `AT+CFUN=1,1`) |
|
||||||
|
| `AT+GTDUALSIM` | Dual SIM switch |
|
||||||
|
| `AT+CMEC` | Mobile termination control mode |
|
||||||
|
| `AT+CMER` | Mobile termination event reporting |
|
||||||
|
| `AT+EFUN` | Functionality for multiple SIM |
|
||||||
|
| `AT+MSMPD` | SIM hot plug enable/disable |
|
||||||
|
| `AT+CPWROFF` | Switch off MS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Call control (Ch. 5)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `ATD` | Dial (data call) |
|
||||||
|
| `ATH` | Hang up |
|
||||||
|
| `AT+CUSD` | Unstructured Supplementary Service Data (USSD) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phone book & clock (Ch. 6–7)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+CPBS` | Select phone book memory |
|
||||||
|
| `AT+CPBR` | Read phone book entries |
|
||||||
|
| `AT+CPBF` | Find phone book entries |
|
||||||
|
| `AT+CPBW` | Write phone book entry |
|
||||||
|
| `AT+CCLK` | Read/set system date and time |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. SMS (Ch. 8)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+CSCS` | Character set |
|
||||||
|
| `AT+CSMS` | Select message service |
|
||||||
|
| `AT+CPMS` | Preferred message storage |
|
||||||
|
| `AT+CMGF` | Message format (0=PDU, 1=text) |
|
||||||
|
| `AT+CSCA` | SMS centre address |
|
||||||
|
| `AT+CSMP` | Set SMS text mode parameters |
|
||||||
|
| `AT+CSDH` | Show SMS text mode parameters |
|
||||||
|
| `AT+CNMI` | New message indications |
|
||||||
|
| `AT+CNMA` | New message acknowledgement |
|
||||||
|
| `AT+CMGL` | List messages |
|
||||||
|
| `AT+CMGR` | Read message |
|
||||||
|
| `AT+CMSS` | Send message from storage |
|
||||||
|
| `AT+CMGW` | Write message to storage |
|
||||||
|
| `AT+CMGD` | Delete message |
|
||||||
|
| `AT+CGSMS` | Select service for MO SMS |
|
||||||
|
| `AT+CMGS` | Send message |
|
||||||
|
| `AT+CSAS` | Save SMS settings |
|
||||||
|
| `AT+CRES` | Restore SMS settings |
|
||||||
|
| `AT+CMMS` | More messages to send |
|
||||||
|
| `AT+CSCB` | Select cell broadcast messages |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. SIM / security (Ch. 10)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+CPIN` | PIN management (e.g. PIN enter) |
|
||||||
|
| `AT+CPWD` | Change password |
|
||||||
|
| `AT+CLCK` | Facility lock |
|
||||||
|
| `AT+CSIM` | Generic SIM access (transmit) |
|
||||||
|
| `AT+CRSM` | Restricted SIM access |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Network (Ch. 11)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+CSQ` | Signal quality (RSSI) |
|
||||||
|
| `AT+CESQ` | Extended signal quality (5G: ss_rsrq, ss_rsrp, ss_sinr) |
|
||||||
|
| `AT+CREG` | Network registration status (2G/3G) |
|
||||||
|
| `AT+CGREG` | GPRS registration |
|
||||||
|
| `AT+CEREG` | EPS (LTE/5G) registration |
|
||||||
|
| `AT+COPS` | Operator selection |
|
||||||
|
| `AT+CPOL` | Preferred operator list |
|
||||||
|
| `AT+COPN` | Operator names |
|
||||||
|
| `AT+CEMODE` | RAT mode (e.g. LTE/5G preference) |
|
||||||
|
| `AT+PACSP` | RAT priority / URC |
|
||||||
|
| `AT+ERAT` | RAT (radio access technology) |
|
||||||
|
| `AT+EPBSEH` | (RAT / band related) |
|
||||||
|
| `AT+EPRATL` | (RAT list) |
|
||||||
|
| `AT+GTACT` | Active RAT / band info |
|
||||||
|
| `AT+GTCCINFO` | Cell / connection info |
|
||||||
|
| `AT+GTCAINFO` | Carrier aggregation info |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. GPRS / PDP context (Ch. 12) – data connection
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+CGDCONT` | Define PDP context (set APN, etc.) |
|
||||||
|
| `AT+CGATT` | Packet domain attach/detach |
|
||||||
|
| `AT+CGACT` | PDP context activate/deactivate |
|
||||||
|
| `AT+CGPADDR` | Show PDP addresses (assigned IP) |
|
||||||
|
| `AT+CGEQREQ` | QoS profile (requested) |
|
||||||
|
| `AT+CGCMOD` | PDP context modify |
|
||||||
|
| `AT+CGDATA` | Enter data state |
|
||||||
|
| `AT+CGDSCONT` | Define secondary PDP context |
|
||||||
|
| `AT+CGCONTRDP` | PDP context read dynamic parameters (IP, DNS, MTU) |
|
||||||
|
| `AT+CGSCONTRDP` | Secondary context read dynamic parameters |
|
||||||
|
| `AT+CGTFTRDP` | (Context read dynamic parameters) |
|
||||||
|
| `AT+CSCON` | Connection state (e.g. 5G NR state) |
|
||||||
|
| `AT+EIAAPN` | (APN / interworking) |
|
||||||
|
| `AT+E5GOPT` | 5G option |
|
||||||
|
| `AT+EAPNACT` | APN activation |
|
||||||
|
| `AT+GTDNS` | Get/set DNS for context |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Hardware / platform (Ch. 13)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **`AT+GTUSBMODE`** | **USB mode (40 = RNDIS, 41 = extended)** |
|
||||||
|
| `AT+GTFMODE` | (Firmware / feature mode) |
|
||||||
|
| `AT+GTDIPCMODE` | Dual IPC mode |
|
||||||
|
| `AT+GTREGWRITE` | Register write (platform) |
|
||||||
|
| `AT+GTRXPATHEN` | Rx path enable |
|
||||||
|
| `AT+GTTXPWR` | Max TX power |
|
||||||
|
| `AT+GTSAR3DBFB` | 3 dB per CC power setback (SAR) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Body SAR (Ch. 14)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+BODYSAREN` | Enable/disable body SAR |
|
||||||
|
| `AT+BODYSARMODE` | Control mode |
|
||||||
|
| `AT+BODYSARRULE` | Regulatory rule |
|
||||||
|
| `AT+BODYSARTRIGIDX` | SAR table trigger index |
|
||||||
|
| `AT+BODYSARON` | Trigger SAR (SW mode) |
|
||||||
|
| `AT+BODYSARPROFILE` | SAR table index to modify |
|
||||||
|
| `AT+BODYSARCFG` | Max TX power limit (antenna combination) |
|
||||||
|
| `AT+BODYSARVER` | SAR NVM version |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. TA-SAR (Ch. 15)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+GTTASEN` | Enable/disable TA-SAR |
|
||||||
|
| `AT+GTTASMODE` | Control mode |
|
||||||
|
| `AT+GTTASRULE` | Regulatory rule |
|
||||||
|
| `AT+GTTASCTRL` | TA-SAR control parameters |
|
||||||
|
| `AT+GTTASTRIGIDX` | SAR table trigger index |
|
||||||
|
| `AT+GTTASON` | Trigger TA-SAR (SW mode) |
|
||||||
|
| `AT+GTTASPROFILE` | SAR table index to modify |
|
||||||
|
| `AT+GTTASCFG` | Max TX power limit (antenna combination) |
|
||||||
|
| `AT+GTTASCLEAR` | Clear TA-SAR config |
|
||||||
|
| `AT+GTTASSTATE` | Current TA-SAR state |
|
||||||
|
| `AT+GTTASVER` | SAR NVM version |
|
||||||
|
| `AT+GTTASPLMNEN` | PLMN switch TA-SAR algorithm |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Tunable antenna (Ch. 16)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+GTANTTUNINGEN` | Enable/disable tunable antenna |
|
||||||
|
| `AT+GTANTTUNEMODE` | GPO/MIPI tuning mode |
|
||||||
|
| `AT+GTANTCTRLMODE` | SW/HW tuning control |
|
||||||
|
| `AT+GTANTPROFILE` | Effective profile (SW mode) |
|
||||||
|
| `AT+GTANTGPOCFG` | GPO tuning values |
|
||||||
|
| `AT+GTANTTUNERCFG` | Tuner register value |
|
||||||
|
| `AT+GTANTMIPICFG` | MIPI tuning value |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. FCC lock (Ch. 17)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+GTFCCLOCKMODE` | FCC lock/unlock mode |
|
||||||
|
| `AT+GTFCCLOCKSTATE` | FCC lock/unlock state |
|
||||||
|
| `AT+GTFCCEFFSTATUS` | Current FCC status |
|
||||||
|
| `AT+GTFCCLOCKGEN` | Modem challenge (FCC lock) |
|
||||||
|
| `AT+GTFCCLOCKVER` | FCC lock verify challenge |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Thermal (Ch. 18)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+GTTHERMAL` | Fibocom thermal management on/off |
|
||||||
|
| `AT+GTTHMLEN` | Actuator feature enable |
|
||||||
|
| `AT+GTSENRDTEMP` | Thermal sensor current |
|
||||||
|
| `AT+GTZONERDMAXTEMP` | Thermal zone max temperature |
|
||||||
|
| `AT+GTACTRDLEVEL` | Actuator current level |
|
||||||
|
| `AT+GTTHMLTIMER` | Thermal time interval |
|
||||||
|
| `AT+GTTHMLTIMES` | Thermal management running time |
|
||||||
|
| `AT+GTZONEWRTHD` | Write zone threshold |
|
||||||
|
| `AT+GTZONERDTHD` | Read zone threshold |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. GNSS (Ch. 19)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+GTGPSSWITCH` | GNSS enable/disable |
|
||||||
|
| `AT+GTGPSPOWER` | GNSS power control |
|
||||||
|
| `AT+GTGPSDELAID` | Start mode (e.g. A-GPS) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Error reporting (Ch. 20)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `AT+CMEE` | Mobile equipment error report (0/1/2) |
|
||||||
|
| `AT+CEER` | Extended error report |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands used in this project
|
||||||
|
|
||||||
|
| Command | Use |
|
||||||
|
|---------|-----|
|
||||||
|
| `AT` | Connectivity test |
|
||||||
|
| `AT+CSQ` | Signal strength (e.g. status/healthcheck) |
|
||||||
|
| `AT+CGDCONT=1,"IP","<APN>"` | Set APN |
|
||||||
|
| `AT+CGDCONT?` | Read PDP context (APN) |
|
||||||
|
| `AT+CGACT=1,1` | Activate PDP context 1 |
|
||||||
|
| `AT+CGPADDR=1` | Get modem IP |
|
||||||
|
| `AT+CGCONTRDP=1` | Get DNS, MTU, connection params |
|
||||||
|
| `AT+GTUSBMODE?` | Check USB mode |
|
||||||
|
| `AT+GTUSBMODE=?` | List supported USB modes |
|
||||||
|
| `AT+GTUSBMODE=40` | Set RNDIS (Mode 40) |
|
||||||
|
| `AT+CFUN=1,1` | Full reset modem |
|
||||||
|
|
||||||
|
For full syntax, parameters, and response codes see **Fibocom_FM350_AT Commands User Manual_V2.10.pdf**.
|
||||||
99
docs/DEPLOY.md
Normal file
99
docs/DEPLOY.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Deploying to a device (SSH / first-time setup)
|
||||||
|
|
||||||
|
## Update device (deploy script)
|
||||||
|
|
||||||
|
From your **local machine** (with the repo and SSH access to the device):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default device: root@10.130.60.121
|
||||||
|
./scripts/deploy.sh
|
||||||
|
|
||||||
|
# Or specify target
|
||||||
|
./scripts/deploy.sh root@192.168.1.100
|
||||||
|
|
||||||
|
# Or use env
|
||||||
|
TARGET=root@10.130.60.121 ./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script:
|
||||||
|
1. Copies `etc/`, `scripts/`, `web/`, and `configure_fm350_5g.sh` to the device at `/tmp/Alpine_5G/`
|
||||||
|
2. Runs `./scripts/install.sh` on the device (installs/updates scripts, services, Web GUI)
|
||||||
|
3. Restarts `5g-router` and `5g-webgui` so changes take effect
|
||||||
|
|
||||||
|
Ensure SSH key or password auth works to the target before running.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First-time SSH access
|
||||||
|
|
||||||
|
### 1. Get the device on the network
|
||||||
|
|
||||||
|
- Connect the device via Ethernet (eth0) so it gets an IP (DHCP) or set a static IP during Alpine setup.
|
||||||
|
- Note the device IP (e.g. from your router’s DHCP list or Alpine’s console).
|
||||||
|
|
||||||
|
### 2. SSH key-based login (recommended)
|
||||||
|
|
||||||
|
On your **local machine** (not the device):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a key if you don’t have one
|
||||||
|
ssh-keygen -t ed25519 -C "your@email" -f ~/.ssh/alpine_5g -N ""
|
||||||
|
|
||||||
|
# Copy the public key to the device (replace IP and user)
|
||||||
|
ssh-copy-id -i ~/.ssh/alpine_5g.pub root@10.130.60.121
|
||||||
|
```
|
||||||
|
|
||||||
|
Then connect with the key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/alpine_5g root@10.130.60.121
|
||||||
|
```
|
||||||
|
|
||||||
|
To avoid specifying the key each time, add to `~/.ssh/config`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Host alpine-5g
|
||||||
|
HostName 10.130.60.121
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/alpine_5g
|
||||||
|
```
|
||||||
|
|
||||||
|
Then: `ssh alpine-5g`.
|
||||||
|
|
||||||
|
### 3. Password login
|
||||||
|
|
||||||
|
If you prefer password auth:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@10.130.60.121
|
||||||
|
```
|
||||||
|
|
||||||
|
You’ll be prompted for the root password. Key-based auth is still recommended for scripts and automation.
|
||||||
|
|
||||||
|
### 4. Deploy the repo to the device
|
||||||
|
|
||||||
|
From your **local machine** (repo on your laptop):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy entire repo
|
||||||
|
scp -r /path/to/Alpine_5G root@10.130.60.121:/tmp/
|
||||||
|
|
||||||
|
# Then on the device (SSH in and run):
|
||||||
|
ssh root@10.130.60.121 "cd /tmp/Alpine_5G && chmod +x scripts/install.sh && ./scripts/install.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or clone from git on the device if it has internet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@10.130.60.121
|
||||||
|
apk add git
|
||||||
|
git clone https://github.com/YOUR_USER/Alpine_5G.git /tmp/Alpine_5G
|
||||||
|
cd /tmp/Alpine_5G && ./scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## After deployment
|
||||||
|
|
||||||
|
- Edit `/etc/5g-router.conf` on the device if needed (APN, interfaces).
|
||||||
|
- Start 5G: `service 5g-router start` or `/usr/local/bin/connect-5g.sh`.
|
||||||
|
- Check status: `/usr/local/bin/status-5g.sh`.
|
||||||
|
- Health check for monitoring: `/usr/local/bin/healthcheck-5g.sh` (exit 0 = OK, 1 = down).
|
||||||
71
docs/DNS.md
Normal file
71
docs/DNS.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# DNS for Alpine 5G Router
|
||||||
|
|
||||||
|
The 5G modem gets DNS from the carrier (e.g. via `AT+CGCONTRDP=1`). The router and LAN clients need working DNS.
|
||||||
|
|
||||||
|
## Option 1: Use carrier DNS (automatic)
|
||||||
|
|
||||||
|
When 5G is up, you can put the carrier DNS in `/etc/resolv.conf` so the router itself uses it. CYTA example:
|
||||||
|
|
||||||
|
```
|
||||||
|
nameserver 195.14.130.220
|
||||||
|
nameserver 195.14.154.100
|
||||||
|
```
|
||||||
|
|
||||||
|
To automate: in `connect-5g.sh` or a post-up script, if `DNS_SERVERS` is set in `/etc/5g-router.conf`, write it to `/etc/resolv.conf` after configuring the interface.
|
||||||
|
|
||||||
|
Example in config:
|
||||||
|
|
||||||
|
```
|
||||||
|
DNS_SERVERS="195.14.130.220,195.14.154.100"
|
||||||
|
```
|
||||||
|
|
||||||
|
A one-liner to apply (run after 5G is up):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo "nameserver 195.14.130.220" > /etc/resolv.conf
|
||||||
|
echo "nameserver 195.14.154.100" >> /etc/resolv.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Option 2: dnsmasq (LAN DHCP + DNS)
|
||||||
|
|
||||||
|
dnsmasq can provide DHCP for LAN and act as DNS forwarder. Install and enable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apk add dnsmasq
|
||||||
|
rc-update add dnsmasq default
|
||||||
|
```
|
||||||
|
|
||||||
|
Example `/etc/dnsmasq.conf`:
|
||||||
|
|
||||||
|
```
|
||||||
|
interface=eth0.100
|
||||||
|
dhcp-range=192.168.1.100,192.168.1.200,255.255.255.0,12h
|
||||||
|
dhcp-option=6,192.168.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the router (192.168.1.1) must resolve DNS. Either:
|
||||||
|
|
||||||
|
- Set `/etc/resolv.conf` on the router to carrier or public DNS (8.8.8.8, 1.1.1.1), or
|
||||||
|
- Configure dnsmasq to use upstream servers: `server=195.14.130.220` and `server=195.14.154.100` (or 8.8.8.8).
|
||||||
|
|
||||||
|
So: **router** gets DNS from carrier or public; **LAN clients** get DHCP and DNS from dnsmasq (which forwards to those servers).
|
||||||
|
|
||||||
|
## Option 3: Public DNS only
|
||||||
|
|
||||||
|
Ignore carrier DNS and use public resolvers on the router:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "nameserver 8.8.8.8" > /etc/resolv.conf
|
||||||
|
echo "nameserver 1.1.1.1" >> /etc/resolv.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure `ip_forward` and NAT are set so LAN clients use the router and thus the same DNS path.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Role | DNS source |
|
||||||
|
|------------|--------------------------------------|
|
||||||
|
| Router | `/etc/resolv.conf` (carrier or 8.8.8.8) |
|
||||||
|
| LAN clients| dnsmasq (option 6 = router) or router as gateway + same resolv |
|
||||||
|
|
||||||
|
After changing DNS, restart dnsmasq if used: `service dnsmasq restart`.
|
||||||
86
docs/QUICKSTART.md
Normal file
86
docs/QUICKSTART.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Alpine 5G Router – Quick Start
|
||||||
|
|
||||||
|
Get a new device from zero to 5G router in a few steps.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Raspberry Pi 5 (or compatible) with Alpine Linux installed
|
||||||
|
- Fibocom FM350-GL modem connected via USB (Mode 40: 0e8d:7126)
|
||||||
|
- SIM card with data (e.g. CYTA Cyprus, APN `internet`)
|
||||||
|
|
||||||
|
## 1. Clone repo on the device (or copy files)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If you have git on the device:
|
||||||
|
git clone https://github.com/YOUR_USER/Alpine_5G.git /tmp/Alpine_5G
|
||||||
|
cd /tmp/Alpine_5G
|
||||||
|
|
||||||
|
# Or: copy the repo (e.g. scp -r Alpine_5G root@device:/tmp/)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Install packages (on the device)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable community repo if needed
|
||||||
|
sed -i 's|#.*community|http://mirrors.neterra.net/alpine/v3.23/community|' /etc/apk/repositories
|
||||||
|
apk update
|
||||||
|
apk add iptables libmbim-tools qmi-utils
|
||||||
|
# Optional: dnsmasq for LAN DHCP/DNS, speedtest-cli for speedtests
|
||||||
|
apk add dnsmasq speedtest-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Run install script
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/Alpine_5G
|
||||||
|
chmod +x scripts/install.sh
|
||||||
|
./scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs:
|
||||||
|
|
||||||
|
- `/etc/5g-router.conf` (from example – edit if needed)
|
||||||
|
- `/usr/local/bin/connect-5g.sh`, `status-5g.sh`, `healthcheck-5g.sh`, etc.
|
||||||
|
- `/etc/init.d/5g-router` (OpenRC service)
|
||||||
|
- `/etc/iptables/rules.v4`
|
||||||
|
- Enables `5g-router` at boot
|
||||||
|
|
||||||
|
## 4. Edit config (if needed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vi /etc/5g-router.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Set at least:
|
||||||
|
|
||||||
|
- `APN` – e.g. `internet` for CYTA
|
||||||
|
- `WAN_IF` / `LAN_IF` – default `eth1` and `eth0.100` are usually correct
|
||||||
|
|
||||||
|
Optional: `FAILOVER_ENABLED=yes` and `FAILOVER_IF=eth0` to use ethernet when 5G is down.
|
||||||
|
|
||||||
|
## 5. Start 5G and test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
service 5g-router start
|
||||||
|
# Or run once:
|
||||||
|
/usr/local/bin/connect-5g.sh
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
/usr/local/bin/status-5g.sh
|
||||||
|
|
||||||
|
# Test connectivity
|
||||||
|
ping -c 3 8.8.8.8
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Enable iptables restore at boot (if not already)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rc-update add iptables-restore default
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
The device will bring up 5G at boot. To restart 5G: `service 5g-router restart`.
|
||||||
|
For full docs see [README.md](../README.md) and [5G_MODEM_TROUBLESHOOTING.md](../5G_MODEM_TROUBLESHOOTING.md).
|
||||||
137
docs/WEBGUI.md
Normal file
137
docs/WEBGUI.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Alpine 5G Router – Web GUI
|
||||||
|
|
||||||
|
Web interface with login and role-based access (admin and support). **One HTML page per function** (Status, Logs, Restart 5G, Config, Firewall, Routes, Users) with shared navigation.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
- **URL:** `http://<device-ip>:5000` (e.g. `http://10.130.60.121:5000`)
|
||||||
|
- **Default users:**
|
||||||
|
- **admin** / **admin** – full access (config, firewall, routes, users, logs, status, restart 5G)
|
||||||
|
- **support** / **support** – view status, view logs, restart 5G only (no config/firewall/users)
|
||||||
|
|
||||||
|
**Change default passwords** after first login (admin: Users tab → set password).
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Feature | Admin | Support |
|
||||||
|
|--------------------|-------|--------|
|
||||||
|
| View status | ✓ | ✓ |
|
||||||
|
| View logs | ✓ | ✓ |
|
||||||
|
| Restart 5G | ✓ | ✓ |
|
||||||
|
| Edit config | ✓ | – |
|
||||||
|
| Edit firewall | ✓ | – |
|
||||||
|
| View routes | ✓ | – |
|
||||||
|
| Manage users | ✓ | – |
|
||||||
|
|
||||||
|
## Install and run
|
||||||
|
|
||||||
|
### On the device (after main install)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Python and Flask (Alpine)
|
||||||
|
apk add python3 py3-flask
|
||||||
|
|
||||||
|
# If you used scripts/install.sh, Web GUI is already under /usr/local/share/5g-webgui
|
||||||
|
# Enable and start the service:
|
||||||
|
rc-update add 5g-webgui default
|
||||||
|
service 5g-webgui start
|
||||||
|
|
||||||
|
# Or run manually (foreground)
|
||||||
|
cd /usr/local/share/5g-webgui && ./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### From repo (development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
pip install -r requirements.txt # or: apk add py3-flask
|
||||||
|
python3 app.py
|
||||||
|
# Open http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Set **SECRET_KEY** in production: `export SECRET_KEY="your-random-secret"` before starting the app (or in the OpenRC service).
|
||||||
|
- Use HTTPS in production (put the app behind nginx/caddy with TLS).
|
||||||
|
- Change default admin and support passwords immediately.
|
||||||
|
|
||||||
|
## SQLite database
|
||||||
|
|
||||||
|
The Web GUI uses **SQLite** (`web/data/alpine5g.db`) for:
|
||||||
|
|
||||||
|
- **users** – login accounts (admin/support); migrated from `users.json` on first run if that file existed.
|
||||||
|
- **iptables_rules** – firewall rules (table, rule line, enabled, order). On first load, if the DB is empty, rules are imported from `/etc/iptables/rules.v4`.
|
||||||
|
- **static_routes** – static routes (destination, gateway, dev, metric). Apply runs `ip route add` for each enabled route.
|
||||||
|
|
||||||
|
Firewall and Routes pages in the GUI list/add/edit/delete from the DB and provide an **Apply** button to write iptables and run `iptables-restore`, or run `ip route add` for routes.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| Path (on device) | Purpose |
|
||||||
|
|-------------------------------|----------------------------|
|
||||||
|
| `/usr/local/share/5g-webgui/` | App and static files |
|
||||||
|
| `/usr/local/share/5g-webgui/data/alpine5g.db` | SQLite DB (users, rules, routes) |
|
||||||
|
| `/etc/init.d/5g-webgui` | OpenRC service |
|
||||||
|
| `/var/log/5g-webgui.log` | Service log |
|
||||||
|
|
||||||
|
## Troubleshooting: Modem not up
|
||||||
|
|
||||||
|
If the modem/WAN is not coming up (Status shows WAN state DOWN, no IP, or “No modem AT data”):
|
||||||
|
|
||||||
|
**On the device**, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/usr/local/bin/diag-modem-up.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
(or `./scripts/diag-modem-up.sh` from the repo). It reports:
|
||||||
|
|
||||||
|
- **5g-router service** status
|
||||||
|
- **Config** (WAN_IF, AT_PORT, APN)
|
||||||
|
- **Modem USB** (lsusb Fibocom; Mode 40 vs 41)
|
||||||
|
- **WAN interface** (exists, state, IP)
|
||||||
|
- **Default route** (via 5G or other)
|
||||||
|
- **AT port** (exists, AT response OK?)
|
||||||
|
- **Last log lines** from `/var/log/5g-router.log`
|
||||||
|
- **Ping** test and suggested fixes
|
||||||
|
|
||||||
|
Use this to see why connect-5g.sh failed (e.g. AT port not ready, wrong USB mode, no modem IP).
|
||||||
|
|
||||||
|
## Troubleshooting: No modem AT data
|
||||||
|
|
||||||
|
If the Status page shows **“No modem AT data (check AT port)”**, run the diagnostic **on the device** (SSH or console):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/usr/local/bin/diag-at-port.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
(or `./scripts/diag-at-port.sh` from the repo). It reports:
|
||||||
|
|
||||||
|
- User and groups (whether you’re in **dialout** for serial access)
|
||||||
|
- Serial devices (`/dev/ttyUSB*`, permissions)
|
||||||
|
- Modem in `lsusb` (Fibocom / 0e8d)
|
||||||
|
- Config `AT_PORT` and whether it exists
|
||||||
|
- Raw **AT** probe on each ttyUSB (which port returns **OK**)
|
||||||
|
- Result of `modem-status-at.sh`
|
||||||
|
|
||||||
|
**Typical fixes:** Add the web server user (e.g. the one running gunicorn) to group **dialout**; set `AT_PORT` in `/etc/5g-router.conf` to the port that responds (e.g. `/dev/ttyUSB0`); ensure modem is in USB mode 40 (RNDIS) so the AT port is present.
|
||||||
|
|
||||||
|
## Optional: run behind reverse proxy
|
||||||
|
|
||||||
|
Example with **nginx** (apk add nginx):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name router.local;
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then access via `http://router.local` (port 80) instead of port 5000.
|
||||||
27
etc/5g-router.conf.example
Normal file
27
etc/5g-router.conf.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Alpine 5G Router – configuration example
|
||||||
|
# Copy to /etc/5g-router.conf and edit. Used by connect-5g.sh and configure_fm350_5g.sh
|
||||||
|
|
||||||
|
# Modem
|
||||||
|
AT_PORT="/dev/ttyUSB1"
|
||||||
|
APN="internet"
|
||||||
|
|
||||||
|
# Interfaces (WAN = 5G modem RNDIS, LAN = VLAN for clients)
|
||||||
|
WAN_IF="eth1"
|
||||||
|
LAN_IF="eth0.100"
|
||||||
|
|
||||||
|
# Optional: fallback to eth0 when 5G is down (use ethernet as default route)
|
||||||
|
FAILOVER_ENABLED="no"
|
||||||
|
FAILOVER_IF="eth0"
|
||||||
|
FAILOVER_METRIC="202"
|
||||||
|
|
||||||
|
# Watchdog: re-check 5G connectivity every N seconds (0 = disabled, run once)
|
||||||
|
WATCHDOG_INTERVAL="0"
|
||||||
|
|
||||||
|
# Log signal strength to /var/log/5g-router.log (yes/no)
|
||||||
|
LOG_SIGNAL="yes"
|
||||||
|
|
||||||
|
# Alert when signal CSQ below this (0 = disabled)
|
||||||
|
WEAK_SIGNAL_CSQ="10"
|
||||||
|
|
||||||
|
# Optional: DNS servers (used when 5G is up; comma-separated)
|
||||||
|
# DNS_SERVERS="195.14.130.220,195.14.154.100"
|
||||||
45
etc/init.d/5g-router
Normal file
45
etc/init.d/5g-router
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/sbin/openrc-run
|
||||||
|
# Alpine 5G Router – OpenRC service
|
||||||
|
# Start: run connect-5g.sh. Stop: bring down WAN interface (optional).
|
||||||
|
# Rev: 5 (see REVISION in repo root)
|
||||||
|
|
||||||
|
description="5G modem connection (connect-5g.sh)"
|
||||||
|
command="/usr/local/bin/connect-5g.sh"
|
||||||
|
command_background="yes"
|
||||||
|
output_log="/var/log/5g-router.log"
|
||||||
|
error_log="/var/log/5g-router.log"
|
||||||
|
pidfile="/run/5g-router.pid"
|
||||||
|
# connect-5g.sh runs run_once then sleep infinity so the process stays up; stop with service 5g-router stop
|
||||||
|
|
||||||
|
# Load config for WAN_IF if we need to clean up on stop
|
||||||
|
[ -f /etc/5g-router.conf ] && . /etc/5g-router.conf
|
||||||
|
WAN_IF="${WAN_IF:-eth1}"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
after bootmisc
|
||||||
|
}
|
||||||
|
|
||||||
|
# Idempotent start: if already running, succeed instead of "already running" error
|
||||||
|
start() {
|
||||||
|
if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then
|
||||||
|
ebegin "Starting 5g-router"
|
||||||
|
eend 0 "Already running"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
ebegin "Starting 5g-router"
|
||||||
|
start-stop-daemon --start --background --pidfile "$pidfile" --make-pidfile \
|
||||||
|
--stdout "$output_log" --stderr "$error_log" \
|
||||||
|
--exec "$command"
|
||||||
|
eend $?
|
||||||
|
}
|
||||||
|
|
||||||
|
start_pre() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_post() {
|
||||||
|
# Optionally bring down 5G interface so traffic uses other default route
|
||||||
|
ip link set "$WAN_IF" down 2>/dev/null || true
|
||||||
|
return 0
|
||||||
|
}
|
||||||
27
etc/init.d/5g-webgui
Normal file
27
etc/init.d/5g-webgui
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/sbin/openrc-run
|
||||||
|
# Alpine 5G Router – Web GUI (Flask). Login: admin/support with different permissions.
|
||||||
|
# Rev: 1 (see REVISION in repo root)
|
||||||
|
|
||||||
|
description="5G Router Web GUI (port 5000)"
|
||||||
|
command="/usr/local/share/5g-webgui/run.sh"
|
||||||
|
command_background="yes"
|
||||||
|
pidfile="/run/5g-webgui.pid"
|
||||||
|
output_log="/var/log/5g-webgui.log"
|
||||||
|
error_log="/var/log/5g-webgui.log"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
after bootmisc
|
||||||
|
}
|
||||||
|
|
||||||
|
start_pre() {
|
||||||
|
if [ ! -f /usr/local/share/5g-webgui/app.py ]; then
|
||||||
|
eend 1 "Web GUI not installed at /usr/local/share/5g-webgui"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
eend 1 "python3 not found. Install: apk add python3 py3-flask"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
24
etc/iptables/rules.v4
Normal file
24
etc/iptables/rules.v4
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Alpine 5G Router – iptables rules (IPv4)
|
||||||
|
# Restored at boot by iptables-restore service. Generated/updated by connect-5g.sh or install.
|
||||||
|
# Ensure 5G WAN interface is eth1 and LAN is eth0.100; adjust if different.
|
||||||
|
|
||||||
|
*filter
|
||||||
|
:INPUT ACCEPT [0:0]
|
||||||
|
:FORWARD ACCEPT [0:0]
|
||||||
|
:OUTPUT ACCEPT [0:0]
|
||||||
|
# Allow web GUI (port 5000) from eth0 (management access)
|
||||||
|
-A INPUT -i eth0 -p tcp --dport 5000 -j ACCEPT
|
||||||
|
# Allow LAN -> WAN (5G)
|
||||||
|
-A FORWARD -i eth0.100 -o eth1 -j ACCEPT
|
||||||
|
# Allow established/related WAN -> LAN
|
||||||
|
-A FORWARD -i eth1 -o eth0.100 -m state --state RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
COMMIT
|
||||||
|
|
||||||
|
*nat
|
||||||
|
:PREROUTING ACCEPT [0:0]
|
||||||
|
:INPUT ACCEPT [0:0]
|
||||||
|
:OUTPUT ACCEPT [0:0]
|
||||||
|
:POSTROUTING ACCEPT [0:0]
|
||||||
|
# NAT LAN traffic going out 5G
|
||||||
|
-A POSTROUTING -o eth1 -j MASQUERADE
|
||||||
|
COMMIT
|
||||||
168
scripts/connect-5g.sh
Normal file
168
scripts/connect-5g.sh
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Alpine 5G Router – connection script (run by 5g-router service or manually)
|
||||||
|
# Uses /etc/5g-router.conf if present. Supports watchdog and failover.
|
||||||
|
# Rev: 2 (see REVISION in repo root)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONFIG="/etc/5g-router.conf"
|
||||||
|
LOG_FILE="/var/log/5g-router.log"
|
||||||
|
|
||||||
|
# Defaults
|
||||||
|
AT_PORT="/dev/ttyUSB1"
|
||||||
|
APN="internet"
|
||||||
|
WAN_IF="eth1"
|
||||||
|
LAN_IF="eth0.100"
|
||||||
|
FAILOVER_ENABLED="no"
|
||||||
|
FAILOVER_IF="eth0"
|
||||||
|
FAILOVER_METRIC="202"
|
||||||
|
WATCHDOG_INTERVAL="0"
|
||||||
|
LOG_SIGNAL="yes"
|
||||||
|
WEAK_SIGNAL_CSQ="10"
|
||||||
|
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
|
||||||
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" 2>/dev/null || echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }
|
||||||
|
|
||||||
|
# Fix broken /dev/ttyUSB* node (if it is a regular file instead of char device - e.g. after modem reconnect)
|
||||||
|
fix_ttyusb_if_needed() {
|
||||||
|
case "$AT_PORT" in
|
||||||
|
/dev/ttyUSB[0-9]*) ;;
|
||||||
|
*) return 0 ;;
|
||||||
|
esac
|
||||||
|
[ ! -e "$AT_PORT" ] && return 0
|
||||||
|
if [ -f "$AT_PORT" ] && [ ! -c "$AT_PORT" ]; then
|
||||||
|
_num="${AT_PORT#/dev/ttyUSB}"
|
||||||
|
log "Fixing $AT_PORT (was regular file, recreating as char device)"
|
||||||
|
rm -f "$AT_PORT"
|
||||||
|
mknod "$AT_PORT" c 188 "$_num"
|
||||||
|
chmod 660 "$AT_PORT"
|
||||||
|
chown root:dialout "$AT_PORT" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_at_response() {
|
||||||
|
_cmd="$1"
|
||||||
|
_wait="${2:-2}"
|
||||||
|
timeout $((_wait + 4)) sh -c "
|
||||||
|
cat $AT_PORT 2>/dev/null &
|
||||||
|
_pid=\$!
|
||||||
|
sleep 0.5
|
||||||
|
echo \"${_cmd}\r\" > $AT_PORT
|
||||||
|
sleep $_wait
|
||||||
|
kill \$_pid 2>/dev/null
|
||||||
|
" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_modem() {
|
||||||
|
log "Waiting for modem AT port..."
|
||||||
|
for _i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||||
|
fix_ttyusb_if_needed
|
||||||
|
if [ -c "$AT_PORT" ]; then
|
||||||
|
log "Modem AT port available"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
log "Modem AT port not available"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_usb_mode() {
|
||||||
|
if lsusb 2>/dev/null | grep -q "0e8d:7127"; then
|
||||||
|
log "WARN: Modem is in Mode 41 (7127). AT commands may not work. Reboot or power-cycle modem."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
do_connect() {
|
||||||
|
# eth1 (RNDIS) does NOT provide DHCP. We get IP (and DNS) only via AT commands
|
||||||
|
# and configure the interface manually. Do not run dhclient on eth1.
|
||||||
|
check_usb_mode || return 1
|
||||||
|
# Modem can take 3–5s to respond after boot; use 5s so service (started at boot) gets OK
|
||||||
|
get_at_response "AT" 5 | grep -q "OK" || { log "AT not OK (try longer wait or different AT port)"; return 1; }
|
||||||
|
|
||||||
|
# Signal (optional log)
|
||||||
|
_resp=$(get_at_response "AT+CSQ" 1)
|
||||||
|
_csq=$(echo "$_resp" | grep "+CSQ:" | grep -oE '[0-9]+' | head -1)
|
||||||
|
[ -n "$_csq" ] && [ "$LOG_SIGNAL" = "yes" ] && echo "$(date -Iseconds) CSQ=$_csq" >> "$LOG_FILE" 2>/dev/null || true
|
||||||
|
[ -n "$_csq" ] && [ -n "$WEAK_SIGNAL_CSQ" ] && [ "$WEAK_SIGNAL_CSQ" -gt 0 ] && [ "$_csq" -lt "$WEAK_SIGNAL_CSQ" ] && log "WARN weak signal CSQ=$_csq"
|
||||||
|
|
||||||
|
log "Configuring APN: $APN"
|
||||||
|
get_at_response "AT+CGDCONT=1,\"IP\",\"$APN\"" 2 | grep -q "OK" || true
|
||||||
|
log "Activating PDP context..."
|
||||||
|
get_at_response "AT+CGACT=1,1" 3 | grep -qE "(OK|CGEV)" || true
|
||||||
|
|
||||||
|
_ip=""
|
||||||
|
for _retry in 1 2 3 4 5; do
|
||||||
|
_resp=$(get_at_response "AT+CGPADDR=1" 2)
|
||||||
|
_ip=$(echo "$_resp" | grep "+CGPADDR:" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||||
|
[ -n "$_ip" ] && break
|
||||||
|
[ $_retry -lt 5 ] && log "No IP yet, retry $_retry/5 in 3s..." && sleep 3
|
||||||
|
done
|
||||||
|
[ -z "$_ip" ] && log "Could not get modem IP (check signal/registration: AT+CSQ, AT+CEREG?)" && return 1
|
||||||
|
|
||||||
|
# Get DNS from modem (AT+CGCONTRDP=1); modem does not provide DHCP, only IP/DNS via AT
|
||||||
|
_dns1=""; _dns2=""
|
||||||
|
_contrdp=$(get_at_response "AT+CGCONTRDP=1" 2)
|
||||||
|
_allips=""
|
||||||
|
for _a in $(echo "$_contrdp" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+'); do
|
||||||
|
[ "$_a" = "$_ip" ] && continue
|
||||||
|
_allips="${_allips} ${_a}"
|
||||||
|
done
|
||||||
|
for _a in $_allips; do [ -z "$_a" ] && continue; _dns2="$_dns1"; _dns1="$_a"; done
|
||||||
|
[ -z "$_dns1" ] && [ -n "$DNS_SERVERS" ] && _dns1=$(echo "$DNS_SERVERS" | cut -d',' -f1 | tr -d ' ') && _dns2=$(echo "$DNS_SERVERS" | cut -d',' -f2 | tr -d ' ')
|
||||||
|
if [ -n "$_dns1" ]; then
|
||||||
|
: > /etc/resolv.conf
|
||||||
|
echo "nameserver $_dns1" >> /etc/resolv.conf
|
||||||
|
[ -n "$_dns2" ] && echo "nameserver $_dns2" >> /etc/resolv.conf
|
||||||
|
log "DNS set (from modem or config)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Configuring $WAN_IF with IP $_ip (no DHCP - from AT+CGPADDR=1)"
|
||||||
|
ip link set "$WAN_IF" up 2>/dev/null || true
|
||||||
|
ip addr flush dev "$WAN_IF" 2>/dev/null || true
|
||||||
|
ip addr add "$_ip/32" dev "$WAN_IF"
|
||||||
|
ip route add default dev "$WAN_IF" metric 50 2>/dev/null || true
|
||||||
|
log "Interface configured"
|
||||||
|
|
||||||
|
log "Setting up NAT..."
|
||||||
|
echo 1 > /proc/sys/net/ipv4/ip_forward
|
||||||
|
iptables -t nat -C POSTROUTING -o "$WAN_IF" -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -o "$WAN_IF" -j MASQUERADE
|
||||||
|
iptables -C FORWARD -i "$LAN_IF" -o "$WAN_IF" -j ACCEPT 2>/dev/null || iptables -A FORWARD -i "$LAN_IF" -o "$WAN_IF" -j ACCEPT
|
||||||
|
iptables -C FORWARD -i "$WAN_IF" -o "$LAN_IF" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || iptables -A FORWARD -i "$WAN_IF" -o "$LAN_IF" -m state --state RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
log "NAT configured"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
do_failover() {
|
||||||
|
[ "$FAILOVER_ENABLED" != "yes" ] && return 0
|
||||||
|
# Ensure there is a default route via failover interface (higher metric so 5G wins when up)
|
||||||
|
ip route add default dev "$FAILOVER_IF" metric "$FAILOVER_METRIC" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
run_once() {
|
||||||
|
wait_for_modem || { do_failover; return 1; }
|
||||||
|
if do_connect; then
|
||||||
|
log "5G connection successful!"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
do_failover
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "$WATCHDOG_INTERVAL" ] && [ "$WATCHDOG_INTERVAL" -gt 0 ]; then
|
||||||
|
log "Starting 5G connection (watchdog every ${WATCHDOG_INTERVAL}s)..."
|
||||||
|
while true; do
|
||||||
|
run_once || true
|
||||||
|
sleep "$WATCHDOG_INTERVAL"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
log "Starting 5G connection..."
|
||||||
|
run_once || true
|
||||||
|
log "Holding process for service (stop with: service 5g-router stop)."
|
||||||
|
exec sleep infinity
|
||||||
|
fi
|
||||||
47
scripts/deploy.sh
Executable file
47
scripts/deploy.sh
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Alpine 5G Router – deploy/update device from this repo
|
||||||
|
# Run from repo root: ./scripts/deploy.sh [user@host]
|
||||||
|
# Or: TARGET=root@192.168.1.1 ./scripts/deploy.sh
|
||||||
|
#
|
||||||
|
# Copies etc/, scripts/, web/, configure_fm350_5g.sh to the device,
|
||||||
|
# runs install.sh there, then restarts 5g-router and 5g-webgui.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
TARGET="${1:-${TARGET:-root@10.130.60.121}}"
|
||||||
|
REMOTE_DIR="/tmp/Alpine_5G"
|
||||||
|
SSH_OPTS="${SSH_OPTS:--o ConnectTimeout=10}"
|
||||||
|
|
||||||
|
echo "Deploying Alpine 5G Router to $TARGET"
|
||||||
|
echo " Repo root: $ROOT"
|
||||||
|
echo " Remote dir: $REMOTE_DIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Copy files to device
|
||||||
|
echo "[1/3] Copying files to $TARGET:$REMOTE_DIR ..."
|
||||||
|
ssh $SSH_OPTS "$TARGET" "mkdir -p $REMOTE_DIR"
|
||||||
|
scp $SSH_OPTS -r "$ROOT/etc" "$ROOT/scripts" "$ROOT/configure_fm350_5g.sh" "$TARGET:$REMOTE_DIR/"
|
||||||
|
if [ -d "$ROOT/web" ] && [ -f "$ROOT/web/app.py" ]; then
|
||||||
|
scp $SSH_OPTS -r "$ROOT/web" "$TARGET:$REMOTE_DIR/"
|
||||||
|
fi
|
||||||
|
echo " Done."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Run install on device
|
||||||
|
echo "[2/3] Running install.sh on device ..."
|
||||||
|
ssh $SSH_OPTS "$TARGET" "cd $REMOTE_DIR && chmod +x scripts/*.sh 2>/dev/null; chmod +x etc/init.d/* 2>/dev/null; ./scripts/install.sh"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Restart services to pick up changes
|
||||||
|
echo "[3/3] Restarting services ..."
|
||||||
|
ssh $SSH_OPTS "$TARGET" "
|
||||||
|
service 5g-router restart 2>/dev/null || true
|
||||||
|
service 5g-webgui restart 2>/dev/null || true
|
||||||
|
echo ' Done.'
|
||||||
|
"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Deployment complete. Device: $TARGET"
|
||||||
|
echo " Web GUI: http://<device-ip>:5000"
|
||||||
|
echo " SSH: ssh $TARGET"
|
||||||
92
scripts/diag-at-port.sh
Normal file
92
scripts/diag-at-port.sh
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Alpine 5G Router – AT port diagnostic (run on device to debug “No modem AT data”)
|
||||||
|
# Rev: 1 (see REVISION in repo root)
|
||||||
|
# Usage: ./diag-at-port.sh or /usr/local/bin/diag-at-port.sh
|
||||||
|
|
||||||
|
CONFIG="/etc/5g-router.conf"
|
||||||
|
AT_PORT="${AT_PORT:-/dev/ttyUSB1}"
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
|
||||||
|
echo "=== Alpine 5G Router – AT port diagnostic ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1) User and permissions
|
||||||
|
echo "--- User & groups ---"
|
||||||
|
echo "User: $(id -un) (uid=$(id -u) gid=$(id -g))"
|
||||||
|
echo "Groups: $(id -Gn)"
|
||||||
|
if id -Gn | grep -q dialout; then
|
||||||
|
echo "dialout: YES (can access serial ports)"
|
||||||
|
else
|
||||||
|
echo "dialout: NO – add user to dialout: adduser <user> dialout"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2) Serial devices
|
||||||
|
echo "--- Serial devices ---"
|
||||||
|
for d in /dev/ttyUSB* /dev/ttyACM*; do
|
||||||
|
[ -e "$d" ] || continue
|
||||||
|
ls -la "$d" 2>/dev/null
|
||||||
|
done
|
||||||
|
if ! ls /dev/ttyUSB* /dev/ttyACM* 2>/dev/null | grep -q .; then
|
||||||
|
echo "No /dev/ttyUSB* or /dev/ttyACM* found. Modem may not be in USB mode 40 or not bound."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 3) Modem USB
|
||||||
|
echo "--- Modem (lsusb) ---"
|
||||||
|
lsusb 2>/dev/null | grep -i fibocom || echo "No Fibocom device in lsusb"
|
||||||
|
lsusb 2>/dev/null | grep -i "0e8d" || true
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4) Config
|
||||||
|
echo "--- Config ---"
|
||||||
|
echo "AT_PORT from config: $AT_PORT"
|
||||||
|
if [ -c "$AT_PORT" ]; then
|
||||||
|
echo " -> exists and is a character device"
|
||||||
|
else
|
||||||
|
echo " -> missing or not a character device"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 5) Raw AT probe on each ttyUSB
|
||||||
|
echo "--- Raw AT probe (send AT, show response) ---"
|
||||||
|
for port in /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2 /dev/ttyUSB3; do
|
||||||
|
[ -c "$port" ] || continue
|
||||||
|
echo "Port $port:"
|
||||||
|
out=$(timeout 4 sh -c "
|
||||||
|
cat $port 2>/dev/null &
|
||||||
|
_p=\$!
|
||||||
|
sleep 0.3
|
||||||
|
echo -e 'AT\r' > $port 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
kill \$_p 2>/dev/null
|
||||||
|
" 2>/dev/null)
|
||||||
|
if echo "$out" | grep -q 'OK'; then
|
||||||
|
echo " -> OK (modem responded)"
|
||||||
|
else
|
||||||
|
echo " -> no OK in response (raw below)"
|
||||||
|
fi
|
||||||
|
echo "$out" | head -5 | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
if ! ls /dev/ttyUSB* 2>/dev/null | grep -q .; then
|
||||||
|
echo "No ttyUSB devices to probe."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 6) modem-status-at.sh if present
|
||||||
|
echo "--- modem-status-at.sh ---"
|
||||||
|
if [ -x "/usr/local/bin/modem-status-at.sh" ]; then
|
||||||
|
echo "Running: /usr/local/bin/modem-status-at.sh"
|
||||||
|
out=$(timeout 20 /usr/local/bin/modem-status-at.sh 2>&1)
|
||||||
|
code=$?
|
||||||
|
echo "Exit code: $code"
|
||||||
|
echo "$out" | head -15
|
||||||
|
if [ "$code" != 0 ] || echo "$out" | grep -q '^{}$'; then
|
||||||
|
echo "... (no modem JSON or script failed)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Not found at /usr/local/bin/modem-status-at.sh (install with scripts/install.sh)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "=== End diagnostic ==="
|
||||||
131
scripts/diag-modem-up.sh
Normal file
131
scripts/diag-modem-up.sh
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Alpine 5G Router – modem/WAN diagnostic (run on device to debug “modem not up”)
|
||||||
|
# Rev: 1 (see REVISION in repo root)
|
||||||
|
# Usage: ./diag-modem-up.sh or /usr/local/bin/diag-modem-up.sh
|
||||||
|
|
||||||
|
CONFIG="/etc/5g-router.conf"
|
||||||
|
WAN_IF="${WAN_IF:-eth1}"
|
||||||
|
AT_PORT="${AT_PORT:-/dev/ttyUSB1}"
|
||||||
|
LOG_FILE="/var/log/5g-router.log"
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
|
||||||
|
echo "=== Alpine 5G Router – modem/WAN diagnostic ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1) Service
|
||||||
|
echo "--- 5g-router service ---"
|
||||||
|
if command -v rc-service >/dev/null 2>&1; then
|
||||||
|
rc-service 5g-router status 2>&1 || true
|
||||||
|
else
|
||||||
|
echo "OpenRC not found; service status skipped"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2) Config
|
||||||
|
echo "--- Config ($CONFIG) ---"
|
||||||
|
if [ -f "$CONFIG" ]; then
|
||||||
|
echo "WAN_IF=$WAN_IF AT_PORT=$AT_PORT"
|
||||||
|
grep -E '^APN=|^WAN_IF=|^AT_PORT=' "$CONFIG" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "Config file not found (using defaults)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 3) Modem USB
|
||||||
|
echo "--- Modem (lsusb) ---"
|
||||||
|
MODEM_LINE=$(lsusb 2>/dev/null | grep -i fibocom || lsusb 2>/dev/null | grep "0e8d" || true)
|
||||||
|
if [ -n "$MODEM_LINE" ]; then
|
||||||
|
echo "$MODEM_LINE"
|
||||||
|
if echo "$MODEM_LINE" | grep -q "7127"; then
|
||||||
|
echo " -> Mode 41 (0e8d:7127): AT port may not work; use Mode 40 (0e8d:7126)"
|
||||||
|
elif echo "$MODEM_LINE" | grep -q "7126"; then
|
||||||
|
echo " -> Mode 40 (RNDIS): AT port should be available"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No Fibocom / 0e8d device – modem not seen by USB"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4) WAN interface
|
||||||
|
echo "--- WAN interface ($WAN_IF) ---"
|
||||||
|
if ip link show "$WAN_IF" >/dev/null 2>&1; then
|
||||||
|
ip link show "$WAN_IF"
|
||||||
|
WAN_STATE=$(ip link show "$WAN_IF" 2>/dev/null | grep -oE 'state [A-Z]+' | awk '{print $2}')
|
||||||
|
WAN_IP=$(ip -4 addr show "$WAN_IF" 2>/dev/null | grep -oE 'inet [0-9.]+' | awk '{print $2}')
|
||||||
|
echo " State: $WAN_STATE IP: ${WAN_IP:-none}"
|
||||||
|
if [ "$WAN_STATE" = "DOWN" ]; then
|
||||||
|
echo " -> Interface is DOWN (connect-5g.sh may have failed or not run)"
|
||||||
|
fi
|
||||||
|
if [ -z "$WAN_IP" ]; then
|
||||||
|
echo " -> No IP (PDP not activated or modem did not assign address)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Interface $WAN_IF does not exist (wrong name or modem not bound?)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 5) Default route
|
||||||
|
echo "--- Default route ---"
|
||||||
|
DEFAULT=$(ip route show default 2>/dev/null | head -1)
|
||||||
|
if [ -n "$DEFAULT" ]; then
|
||||||
|
echo "$DEFAULT"
|
||||||
|
DEF_DEV=$(echo "$DEFAULT" | awk '{print $3}')
|
||||||
|
if [ "$DEF_DEV" != "$WAN_IF" ]; then
|
||||||
|
echo " -> Default is via $DEF_DEV, not $WAN_IF (5G may be down or failover active)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " No default route"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 6) AT port
|
||||||
|
echo "--- AT port ($AT_PORT) ---"
|
||||||
|
if [ -c "$AT_PORT" ]; then
|
||||||
|
echo " Exists (character device)"
|
||||||
|
out=$(timeout 4 sh -c "
|
||||||
|
cat $AT_PORT 2>/dev/null &
|
||||||
|
_p=\$!
|
||||||
|
sleep 0.3
|
||||||
|
echo -e 'AT\r' > $AT_PORT 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
kill \$_p 2>/dev/null
|
||||||
|
" 2>/dev/null)
|
||||||
|
if echo "$out" | grep -q 'OK'; then
|
||||||
|
echo " AT response: OK"
|
||||||
|
else
|
||||||
|
echo " AT response: no OK (raw: $(echo "$out" | head -1))"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Not available (missing or not a char device) – connect-5g.sh waits for this"
|
||||||
|
for p in /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2; do
|
||||||
|
[ -c "$p" ] && echo " Found: $p"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 7) Last log lines
|
||||||
|
echo "--- Last 20 lines of $LOG_FILE ---"
|
||||||
|
if [ -f "$LOG_FILE" ]; then
|
||||||
|
tail -20 "$LOG_FILE" 2>/dev/null | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " Log file not found"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 8) Connectivity
|
||||||
|
echo "--- Connectivity (ping 8.8.8.8) ---"
|
||||||
|
if ping -c 1 -W 3 8.8.8.8 >/dev/null 2>&1; then
|
||||||
|
echo " Reachable (modem/route may be OK)"
|
||||||
|
else
|
||||||
|
echo " Not reachable (WAN down or no default via 5G)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "=== End diagnostic ==="
|
||||||
|
echo ""
|
||||||
|
echo "Typical fixes:"
|
||||||
|
echo " - Modem not in lsusb: power-cycle modem or USB; check cable"
|
||||||
|
echo " - Mode 41 (7127): reboot modem or run AT+GTUSBMODE=40 then AT+CFUN=1,1"
|
||||||
|
echo " - AT port missing: wait for modem to expose ttyUSB (can take 30s after boot)"
|
||||||
|
echo " - AT no OK: wrong port – set AT_PORT in $CONFIG (e.g. /dev/ttyUSB0)"
|
||||||
|
echo " - No modem IP: check APN in $CONFIG; check SIM/network registration"
|
||||||
|
echo " - Start connection: service 5g-router start or /usr/local/bin/connect-5g.sh"
|
||||||
44
scripts/healthcheck-5g.sh
Normal file
44
scripts/healthcheck-5g.sh
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Alpine 5G Router – health check for monitoring (Nagios, Uptime Kuma, etc.)
|
||||||
|
# Exit 0 = OK, 1 = 5G down or no connectivity
|
||||||
|
# Usage: healthcheck-5g.sh [--ping-only]
|
||||||
|
# Rev: 1 (see REVISION in repo root)
|
||||||
|
|
||||||
|
CONFIG="/etc/5g-router.conf"
|
||||||
|
WAN_IF="${WAN_IF:-eth1}"
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
|
||||||
|
PING_TARGET="${PING_TARGET:-8.8.8.8}"
|
||||||
|
|
||||||
|
# Check default route goes via 5G
|
||||||
|
def_if=$(ip route show default 2>/dev/null | awk '{print $5}' | head -1)
|
||||||
|
if [ "$def_if" != "$WAN_IF" ]; then
|
||||||
|
echo "CRITICAL: default route not via $WAN_IF (got: $def_if)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check WAN has an address
|
||||||
|
wan_ip=$(ip -4 addr show "$WAN_IF" 2>/dev/null | grep -oE 'inet [0-9.]+' | awk '{print $2}')
|
||||||
|
if [ -z "$wan_ip" ]; then
|
||||||
|
echo "CRITICAL: no IP on $WAN_IF"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$1" = "--ping-only" ]; then
|
||||||
|
if ping -c 1 -W 3 "$PING_TARGET" >/dev/null 2>&1; then
|
||||||
|
echo "OK: ping $PING_TARGET"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "CRITICAL: ping $PING_TARGET failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Full check: route + ping
|
||||||
|
if ping -c 2 -W 5 "$PING_TARGET" >/dev/null 2>&1; then
|
||||||
|
echo "OK: 5G up, ping $PING_TARGET"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "CRITICAL: 5G route OK but ping $PING_TARGET failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
88
scripts/install.sh
Normal file
88
scripts/install.sh
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Alpine 5G Router – install/deploy scripts and config to this device
|
||||||
|
# Run from repo root: ./scripts/install.sh
|
||||||
|
# Or from scripts/: ./install.sh (uses script dir to find repo root)
|
||||||
|
# Rev: 1 (see REVISION in repo root)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Repo root (directory containing etc/ and scripts/)
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ETC="$ROOT/etc"
|
||||||
|
SCRIPTS="$ROOT/scripts"
|
||||||
|
BIN="/usr/local/bin"
|
||||||
|
INITD="/etc/init.d"
|
||||||
|
IPTABLES_SAVE="/etc/iptables"
|
||||||
|
CONFIG="/etc/5g-router.conf"
|
||||||
|
CONFIG_EXAMPLE="$ETC/5g-router.conf.example"
|
||||||
|
LOG_DIR="/var/log"
|
||||||
|
|
||||||
|
echo "Installing Alpine 5G Router from $ROOT"
|
||||||
|
|
||||||
|
# Config: copy example if no config exists
|
||||||
|
if [ ! -f "$CONFIG" ]; then
|
||||||
|
cp "$CONFIG_EXAMPLE" "$CONFIG"
|
||||||
|
echo "Created $CONFIG – please edit APN and interfaces if needed."
|
||||||
|
else
|
||||||
|
echo "Keeping existing $CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Scripts
|
||||||
|
install -m 755 "$SCRIPTS/connect-5g.sh" "$BIN/connect-5g.sh"
|
||||||
|
install -m 755 "$SCRIPTS/status-5g.sh" "$BIN/status-5g.sh"
|
||||||
|
install -m 755 "$SCRIPTS/modem-status-at.sh" "$BIN/modem-status-at.sh"
|
||||||
|
install -m 755 "$SCRIPTS/healthcheck-5g.sh" "$BIN/healthcheck-5g.sh"
|
||||||
|
install -m 755 "$SCRIPTS/speedtest-5g.sh" "$BIN/speedtest-5g.sh"
|
||||||
|
install -m 755 "$SCRIPTS/rotate-5g-log.sh" "$BIN/rotate-5g-log.sh"
|
||||||
|
install -m 755 "$SCRIPTS/diag-at-port.sh" "$BIN/diag-at-port.sh"
|
||||||
|
install -m 755 "$SCRIPTS/diag-modem-up.sh" "$BIN/diag-modem-up.sh"
|
||||||
|
install -m 755 "$SCRIPTS/troubleshoot-5g.sh" "$BIN/troubleshoot-5g.sh"
|
||||||
|
echo "Installed scripts to $BIN"
|
||||||
|
|
||||||
|
# Optional: configure_fm350_5g.sh for manual runs
|
||||||
|
if [ -f "$ROOT/configure_fm350_5g.sh" ]; then
|
||||||
|
install -m 755 "$ROOT/configure_fm350_5g.sh" "$BIN/configure_fm350_5g.sh"
|
||||||
|
echo "Installed configure_fm350_5g.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# OpenRC service
|
||||||
|
install -m 755 "$ETC/init.d/5g-router" "$INITD/5g-router"
|
||||||
|
echo "Installed $INITD/5g-router"
|
||||||
|
|
||||||
|
# iptables rules (restore at boot – ensure iptables-restore service exists)
|
||||||
|
mkdir -p "$IPTABLES_SAVE"
|
||||||
|
install -m 644 "$ETC/iptables/rules.v4" "$IPTABLES_SAVE/rules.v4"
|
||||||
|
echo "Installed $IPTABLES_SAVE/rules.v4"
|
||||||
|
if [ -d /etc/init.d ] && [ ! -f /etc/init.d/iptables-restore ]; then
|
||||||
|
echo "Tip: enable iptables restore at boot: rc-update add iptables-restore"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Log file
|
||||||
|
touch "$LOG_DIR/5g-router.log" 2>/dev/null && chmod 644 "$LOG_DIR/5g-router.log" || true
|
||||||
|
touch "$LOG_DIR/speedtest-5g.log" 2>/dev/null && chmod 644 "$LOG_DIR/speedtest-5g.log" || true
|
||||||
|
|
||||||
|
# Enable 5g-router at boot
|
||||||
|
if command -v rc-update >/dev/null 2>&1; then
|
||||||
|
rc-update add 5g-router default 2>/dev/null || true
|
||||||
|
echo "Added 5g-router to default runlevel"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Web GUI (optional)
|
||||||
|
WEBGUI_SRC="$ROOT/web"
|
||||||
|
WEBGUI_DEST="/usr/local/share/5g-webgui"
|
||||||
|
if [ -d "$WEBGUI_SRC" ] && [ -f "$WEBGUI_SRC/app.py" ]; then
|
||||||
|
mkdir -p "$WEBGUI_DEST"
|
||||||
|
cp -r "$WEBGUI_SRC"/* "$WEBGUI_DEST/"
|
||||||
|
chmod 755 "$WEBGUI_DEST/run.sh" 2>/dev/null || true
|
||||||
|
install -m 755 "$ETC/init.d/5g-webgui" "$INITD/5g-webgui"
|
||||||
|
echo "Installed Web GUI at $WEBGUI_DEST. Enable with: rc-update add 5g-webgui default"
|
||||||
|
echo " Then: apk add python3 py3-flask && service 5g-webgui start"
|
||||||
|
echo " Login at http://<device-ip>:5000 (default: admin/admin, support/support – change passwords)"
|
||||||
|
else
|
||||||
|
echo "Web GUI source not found; skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. Edit $CONFIG if needed, then: service 5g-router start"
|
||||||
|
echo "Or run once: $BIN/connect-5g.sh"
|
||||||
|
echo "Status: $BIN/status-5g.sh"
|
||||||
105
scripts/modem-status-at.sh
Normal file
105
scripts/modem-status-at.sh
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Alpine 5G Router – modem status via AT commands (AT_COMMANDS_REFERENCE.md)
|
||||||
|
# Outputs JSON. Used by Web GUI /api/status.
|
||||||
|
# Rev: 2 (see REVISION in repo root)
|
||||||
|
|
||||||
|
CONFIG="/etc/5g-router.conf"
|
||||||
|
AT_PORT="${AT_PORT:-/dev/ttyUSB1}"
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
|
||||||
|
get_at() {
|
||||||
|
_cmd="$1"
|
||||||
|
_wait="${2:-1}"
|
||||||
|
timeout $((_wait + 2)) sh -c "
|
||||||
|
cat $AT_PORT 2>/dev/null &
|
||||||
|
_p=\$!
|
||||||
|
sleep 0.3
|
||||||
|
echo -e '${_cmd}\r' > $AT_PORT
|
||||||
|
sleep $_wait
|
||||||
|
kill \$_p 2>/dev/null
|
||||||
|
" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
json_str() { printf '"%s"' "$(echo "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\r//g' | tr '\n' ' ')"; }
|
||||||
|
|
||||||
|
# Probe port: send AT, return 0 if we see OK
|
||||||
|
try_at_port() {
|
||||||
|
_port="$1"
|
||||||
|
[ ! -c "$_port" ] && return 1
|
||||||
|
_out=$(timeout 4 sh -c "
|
||||||
|
cat $_port 2>/dev/null &
|
||||||
|
_p=\$!
|
||||||
|
sleep 0.3
|
||||||
|
echo -e 'AT\r' > $_port
|
||||||
|
sleep 1
|
||||||
|
kill \$_p 2>/dev/null
|
||||||
|
" 2>/dev/null)
|
||||||
|
echo "$_out" | grep -q 'OK' && return 0 || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use configured port if it exists and is a char device; else try common Fibocom AT ports
|
||||||
|
if [ ! -c "$AT_PORT" ]; then
|
||||||
|
for p in /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2; do
|
||||||
|
if try_at_port "$p"; then
|
||||||
|
AT_PORT="$p"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ ! -c "$AT_PORT" ] && echo '{}' && exit 0
|
||||||
|
|
||||||
|
# Disable command echo so we get only response lines (ATE0); avoids parsing "AT+CGMI" as manufacturer
|
||||||
|
get_at "ATE0" 1 >/dev/null 2>&1
|
||||||
|
sleep 0.2
|
||||||
|
|
||||||
|
# Query AT commands (reference: AT_COMMANDS_REFERENCE.md)
|
||||||
|
r_cgmi=$(get_at "AT+CGMI" 1)
|
||||||
|
r_cgmm=$(get_at "AT+CGMM" 1)
|
||||||
|
r_cgmr=$(get_at "AT+CGMR" 1)
|
||||||
|
r_cgsn=$(get_at "AT+CGSN" 1)
|
||||||
|
r_csq=$(get_at "AT+CSQ" 1)
|
||||||
|
r_creg=$(get_at "AT+CREG?" 1)
|
||||||
|
r_cereg=$(get_at "AT+CEREG?" 1)
|
||||||
|
r_usbmode=$(get_at "AT+GTUSBMODE?" 1)
|
||||||
|
r_ccid=$(get_at "AT+CCID" 1)
|
||||||
|
r_cops=$(get_at "AT+COPS?" 1)
|
||||||
|
r_cgpaddr=$(get_at "AT+CGPADDR=1" 1)
|
||||||
|
|
||||||
|
# Parse (skip echoed command lines: lines starting with AT or +)
|
||||||
|
manufacturer=$(echo "$r_cgmi" | grep -vE '^AT|^\+' | grep -oE '^[A-Za-z0-9 ].*' | head -1 | sed 's/\r$//; s/^ *//; s/ *$//')
|
||||||
|
model=$(echo "$r_cgmm" | grep -vE '^AT|^\+' | grep -oE '^[A-Za-z0-9\-].*' | head -1 | sed 's/\r$//; s/^ *//; s/ *$//')
|
||||||
|
revision=$(echo "$r_cgmr" | grep -vE '^(AT|OK|\+)' | head -1 | sed 's/\r$//; s/^ *//; s/ *$//')
|
||||||
|
imei=$(echo "$r_cgsn" | grep -oE '[0-9]{15}' | head -1)
|
||||||
|
csq=$(echo "$r_csq" | grep "+CSQ:" | grep -oE '[0-9]+' | head -1)
|
||||||
|
creg=$(echo "$r_creg" | grep "+CREG:" | head -1)
|
||||||
|
cereg=$(echo "$r_cereg" | grep "+CEREG:" | head -1)
|
||||||
|
usb_mode=$(echo "$r_usbmode" | grep "+GTUSBMODE:" | grep -oE '[0-9]+' | head -1)
|
||||||
|
iccid=$(echo "$r_ccid" | grep "+CCID:" | sed 's/+CCID:[[:space:]]*//; s/\r$//; s/"//g' | head -1)
|
||||||
|
cops=$(echo "$r_cops" | grep "+COPS:" | sed 's/+COPS:[[:space:]]*//; s/\r$//' | head -1)
|
||||||
|
modem_ip=$(echo "$r_cgpaddr" | grep "+CGPADDR:" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||||
|
|
||||||
|
# Registration: CREG 0,1 = registered home, 0,5 = registered roaming; CEREG same
|
||||||
|
creg_status=""
|
||||||
|
case "$creg" in *",1"*) creg_status="registered_home";; *",5"*) creg_status="registered_roaming";; *",2"*) creg_status="searching";; *",0"*) creg_status="not_registered";; *) creg_status="unknown";; esac
|
||||||
|
cereg_status=""
|
||||||
|
case "$cereg" in *",1"*) cereg_status="registered_home";; *",5"*) cereg_status="registered_roaming";; *",2"*) cereg_status="searching";; *",0"*) cereg_status="not_registered";; *) cereg_status="unknown";; esac
|
||||||
|
|
||||||
|
# Operator name from +COPS: 0,0,"Vodafone",7
|
||||||
|
operator_name=$(echo "$cops" | sed -n 's/.*"\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
|
# Build JSON (no jq)
|
||||||
|
printf '{\n'
|
||||||
|
printf ' "at_port": '; json_str "$AT_PORT"; printf ',\n'
|
||||||
|
printf ' "manufacturer": '; json_str "${manufacturer:-}"; printf ',\n'
|
||||||
|
printf ' "model": '; json_str "${model:-}"; printf ',\n'
|
||||||
|
printf ' "revision": '; json_str "${revision:-}"; printf ',\n'
|
||||||
|
printf ' "imei": '; json_str "${imei:-}"; printf ',\n'
|
||||||
|
printf ' "signal_csq": %s,\n' "${csq:-null}"
|
||||||
|
printf ' "creg_status": '; json_str "$creg_status"; printf ',\n'
|
||||||
|
printf ' "cereg_status": '; json_str "$cereg_status"; printf ',\n'
|
||||||
|
printf ' "usb_mode": '; json_str "${usb_mode:-}"; printf ',\n'
|
||||||
|
printf ' "iccid": '; json_str "${iccid:-}"; printf ',\n'
|
||||||
|
printf ' "operator": '; json_str "${operator_name:-}"; printf ',\n'
|
||||||
|
printf ' "modem_ip": '; json_str "${modem_ip:-}"; printf '\n'
|
||||||
|
printf '}\n'
|
||||||
21
scripts/rotate-5g-log.sh
Normal file
21
scripts/rotate-5g-log.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Rotate /var/log/5g-router.log (keep last 5 copies, max 1MB each)
|
||||||
|
# Run from cron daily: 0 3 * * * /usr/local/bin/rotate-5g-log.sh
|
||||||
|
# Alpine does not ship logrotate by default; this is a minimal alternative.
|
||||||
|
# Rev: 1 (see REVISION in repo root)
|
||||||
|
|
||||||
|
LOG="/var/log/5g-router.log"
|
||||||
|
MAXSIZE=$((1024 * 1024)) # 1MB
|
||||||
|
KEEP=5
|
||||||
|
|
||||||
|
[ ! -f "$LOG" ] && exit 0
|
||||||
|
size=$(stat -c %s "$LOG" 2>/dev/null) || size=$(wc -c < "$LOG" 2>/dev/null) || exit 0
|
||||||
|
[ "$size" -lt "$MAXSIZE" ] && exit 0
|
||||||
|
|
||||||
|
for i in $(seq $((KEEP - 1)) -1 1); do
|
||||||
|
[ -f "${LOG}.$i" ] && mv "${LOG}.$i" "${LOG}.$((i+1))"
|
||||||
|
done
|
||||||
|
[ -f "${LOG}.1" ] && mv "${LOG}.1" "${LOG}.2"
|
||||||
|
mv "$LOG" "${LOG}.1"
|
||||||
|
touch "$LOG"
|
||||||
|
chmod 644 "$LOG" 2>/dev/null || true
|
||||||
14
scripts/speedtest-5g.sh
Normal file
14
scripts/speedtest-5g.sh
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Optional: run speedtest and append to log (for cron)
|
||||||
|
# Requires: apk add speedtest-cli
|
||||||
|
# Cron example: 0 */6 * * * /usr/local/bin/speedtest-5g.sh
|
||||||
|
# Rev: 1 (see REVISION in repo root)
|
||||||
|
|
||||||
|
LOG="/var/log/speedtest-5g.log"
|
||||||
|
if command -v speedtest-cli >/dev/null 2>&1; then
|
||||||
|
echo "=== $(date -Iseconds) ===" >> "$LOG"
|
||||||
|
speedtest-cli --simple >> "$LOG" 2>&1
|
||||||
|
echo "" >> "$LOG"
|
||||||
|
else
|
||||||
|
echo "$(date -Iseconds) speedtest-cli not installed" >> "$LOG"
|
||||||
|
fi
|
||||||
26
scripts/ssh-diag-modem.sh
Executable file
26
scripts/ssh-diag-modem.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Alpine 5G Router – run modem diagnostics on device via SSH
|
||||||
|
# Usage: ./scripts/ssh-diag-modem.sh [user@host]
|
||||||
|
# Or: TARGET=root@192.168.1.1 ./scripts/ssh-diag-modem.sh
|
||||||
|
|
||||||
|
TARGET="${1:-${TARGET:-root@10.130.60.121}}"
|
||||||
|
SSH_OPTS="${SSH_OPTS:--o ConnectTimeout=10}"
|
||||||
|
|
||||||
|
echo "Running modem diagnostics on $TARGET"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
ssh $SSH_OPTS "$TARGET" "
|
||||||
|
echo '========== diag-modem-up.sh =========='
|
||||||
|
if [ -x /usr/local/bin/diag-modem-up.sh ]; then
|
||||||
|
/usr/local/bin/diag-modem-up.sh
|
||||||
|
else
|
||||||
|
echo 'Not installed. Deploy first: ./scripts/deploy.sh $TARGET'
|
||||||
|
echo 'Then re-run this script.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ''
|
||||||
|
echo '========== diag-at-port.sh =========='
|
||||||
|
if [ -x /usr/local/bin/diag-at-port.sh ]; then
|
||||||
|
/usr/local/bin/diag-at-port.sh
|
||||||
|
fi
|
||||||
|
"
|
||||||
44
scripts/status-5g.sh
Normal file
44
scripts/status-5g.sh
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Alpine 5G Router – status (modem, interface, default route, last speedtest)
|
||||||
|
# Usage: status-5g.sh [--json] (optional machine-readable output)
|
||||||
|
# Rev: 1 (see REVISION in repo root)
|
||||||
|
|
||||||
|
CONFIG="/etc/5g-router.conf"
|
||||||
|
WAN_IF="${WAN_IF:-eth1}"
|
||||||
|
LAN_IF="${LAN_IF:-eth0.100}"
|
||||||
|
AT_PORT="${AT_PORT:-/dev/ttyUSB1}"
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
|
||||||
|
SPEEDTEST_LOG="/var/log/speedtest-5g.log"
|
||||||
|
MODEM_USB=$(lsusb 2>/dev/null | grep -i fibocom | head -1)
|
||||||
|
DEFAULT_ROUTE=$(ip route show default 2>/dev/null | head -1)
|
||||||
|
WAN_IP=$(ip -4 addr show "$WAN_IF" 2>/dev/null | grep -oE 'inet [0-9.]+' | awk '{print $2}')
|
||||||
|
WAN_STATE=$(ip link show "$WAN_IF" 2>/dev/null | grep -oE 'state [A-Z]+' | awk '{print $2}')
|
||||||
|
LAST_SPEEDTEST=""
|
||||||
|
[ -f "$SPEEDTEST_LOG" ] && LAST_SPEEDTEST=$(tail -5 "$SPEEDTEST_LOG" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$1" = "--json" ]; then
|
||||||
|
echo "{"
|
||||||
|
echo " \"modem_usb\": \"${MODEM_USB:-none}\","
|
||||||
|
echo " \"wan_interface\": \"$WAN_IF\","
|
||||||
|
echo " \"wan_state\": \"${WAN_STATE:-unknown}\","
|
||||||
|
echo " \"wan_ip\": \"${WAN_IP:-}\","
|
||||||
|
echo " \"default_route\": \"${DEFAULT_ROUTE:-none}\","
|
||||||
|
echo " \"at_port\": \"$AT_PORT\","
|
||||||
|
echo " \"at_available\": \"$([ -c \"$AT_PORT\" ] && echo yes || echo no)\""
|
||||||
|
echo "}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== 5G Router Status ==="
|
||||||
|
echo "Modem: ${MODEM_USB:-not detected}"
|
||||||
|
echo "AT port: $AT_PORT ($([ -c "$AT_PORT" ] && echo "available" || echo "not available"))"
|
||||||
|
echo "WAN: $WAN_IF state=$WAN_STATE ip=$WAN_IP"
|
||||||
|
echo "Default: $DEFAULT_ROUTE"
|
||||||
|
echo ""
|
||||||
|
echo "--- Last speedtest ---"
|
||||||
|
if [ -n "$LAST_SPEEDTEST" ]; then
|
||||||
|
echo "$LAST_SPEEDTEST"
|
||||||
|
else
|
||||||
|
echo "(none – run speedtest-cli --simple and redirect to $SPEEDTEST_LOG)"
|
||||||
|
fi
|
||||||
145
scripts/troubleshoot-5g.sh
Normal file
145
scripts/troubleshoot-5g.sh
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Alpine 5G Router – full troubleshoot: collect all logs and run diagnostics
|
||||||
|
# Run on device: /usr/local/bin/troubleshoot-5g.sh
|
||||||
|
# Or via SSH: ssh root@device /usr/local/bin/troubleshoot-5g.sh
|
||||||
|
# Rev: 1 (see REVISION in repo root)
|
||||||
|
|
||||||
|
CONFIG="/etc/5g-router.conf"
|
||||||
|
AT_PORT="${AT_PORT:-/dev/ttyUSB1}"
|
||||||
|
WAN_IF="${WAN_IF:-eth1}"
|
||||||
|
LOG_FILE="/var/log/5g-router.log"
|
||||||
|
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
|
||||||
|
echo "=============================================="
|
||||||
|
echo " Alpine 5G Router – Full Troubleshoot"
|
||||||
|
echo " $(date -Iseconds)"
|
||||||
|
echo "=============================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- 1) Kernel / USB / tty (recent) ---
|
||||||
|
echo "--- 1) Kernel messages (dmesg, last 40 lines) ---"
|
||||||
|
dmesg 2>/dev/null | tail -40 || echo "(dmesg not available)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- 2) USB devices ---
|
||||||
|
echo "--- 2) USB devices (lsusb) ---"
|
||||||
|
lsusb 2>/dev/null || echo "(lsusb not available)"
|
||||||
|
if lsusb 2>/dev/null | grep -q "0e8d:7127"; then
|
||||||
|
echo " -> WARN: Modem in Mode 41 (7127). AT port may not work. Need Mode 40 (7126)."
|
||||||
|
elif lsusb 2>/dev/null | grep -q "0e8d:7126"; then
|
||||||
|
echo " -> Modem in Mode 40 (RNDIS) – OK for AT on ttyUSB1"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- 3) Serial / AT port nodes ---
|
||||||
|
echo "--- 3) Serial devices (/dev/ttyUSB[0-9]*, /dev/ttyACM[0-9]*) ---"
|
||||||
|
if [ -e /dev/ttyUSB ] && [ -f /dev/ttyUSB ] && [ ! -c /dev/ttyUSB ]; then
|
||||||
|
echo " Stray file /dev/ttyUSB (no number) – remove with: rm /dev/ttyUSB"
|
||||||
|
fi
|
||||||
|
for d in /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2 /dev/ttyUSB3 /dev/ttyUSB4 /dev/ttyUSB5 /dev/ttyACM0 /dev/ttyACM1; do
|
||||||
|
[ -e "$d" ] || continue
|
||||||
|
ls -la "$d" 2>/dev/null
|
||||||
|
if [ -f "$d" ] && [ ! -c "$d" ]; then
|
||||||
|
_n="${d#/dev/ttyUSB}"; _n="${_n#/dev/ttyACM}"
|
||||||
|
echo " -> BAD: $d is a regular file. Fix: rm $d && mknod $d c 188 $_n && chmod 660 $d && chown root:dialout $d"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if ! ls /dev/ttyUSB[0-9]* /dev/ttyACM[0-9]* 2>/dev/null | grep -q .; then
|
||||||
|
echo " No ttyUSB/ttyACM devices. Modem may not be bound or not in Mode 40."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- 4) Config ---
|
||||||
|
echo "--- 4) Config ($CONFIG) ---"
|
||||||
|
if [ -f "$CONFIG" ]; then
|
||||||
|
grep -E '^[A-Za-z_]+=' "$CONFIG" 2>/dev/null | sed 's/^/ /' || true
|
||||||
|
else
|
||||||
|
echo " File not found (using defaults: AT_PORT=$AT_PORT, WAN_IF=$WAN_IF)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- 5) AT port test (with longer wait like connect-5g.sh) ---
|
||||||
|
echo "--- 5) AT command test on $AT_PORT (wait 3s) ---"
|
||||||
|
if [ -c "$AT_PORT" ]; then
|
||||||
|
out=$(timeout 8 sh -c "
|
||||||
|
cat $AT_PORT 2>/dev/null &
|
||||||
|
_p=\$!
|
||||||
|
sleep 0.5
|
||||||
|
echo -e 'AT\r' > $AT_PORT 2>/dev/null
|
||||||
|
sleep 3
|
||||||
|
kill \$_p 2>/dev/null
|
||||||
|
" 2>&1)
|
||||||
|
if echo "$out" | grep -q 'OK'; then
|
||||||
|
echo " AT response: OK (modem responding)"
|
||||||
|
else
|
||||||
|
echo " AT response: no OK (modem not responding or wrong port)"
|
||||||
|
echo " Raw output:"
|
||||||
|
echo "$out" | head -10 | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " $AT_PORT not available (missing or not a character device)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- 6) AT probe on all ttyUSB (which port responds) ---
|
||||||
|
echo "--- 6) AT probe on each ttyUSB port ---"
|
||||||
|
for port in /dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2 /dev/ttyUSB3; do
|
||||||
|
[ -c "$port" ] || continue
|
||||||
|
out=$(timeout 5 sh -c "cat $port 2>/dev/null & _p=\$!; sleep 0.3; echo -e 'AT\r' > $port; sleep 2; kill \$_p 2>/dev/null" 2>/dev/null)
|
||||||
|
if echo "$out" | grep -q 'OK'; then
|
||||||
|
echo " $port: OK (use this as AT_PORT if different from config)"
|
||||||
|
else
|
||||||
|
echo " $port: no OK"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- 7) 5g-router service ---
|
||||||
|
echo "--- 7) 5g-router service ---"
|
||||||
|
if command -v rc-service >/dev/null 2>&1; then
|
||||||
|
rc-service 5g-router status 2>&1 | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " OpenRC not found"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- 8) WAN interface and routing ---
|
||||||
|
echo "--- 8) WAN interface ($WAN_IF) and default route ---"
|
||||||
|
if ip link show "$WAN_IF" >/dev/null 2>&1; then
|
||||||
|
ip link show "$WAN_IF" 2>/dev/null | sed 's/^/ /'
|
||||||
|
ip -4 addr show "$WAN_IF" 2>/dev/null | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " Interface $WAN_IF does not exist"
|
||||||
|
fi
|
||||||
|
ip route show default 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- 9) Full 5g-router log ---
|
||||||
|
echo "--- 9) Full 5g-router log (last 60 lines of $LOG_FILE) ---"
|
||||||
|
if [ -f "$LOG_FILE" ]; then
|
||||||
|
tail -60 "$LOG_FILE" 2>/dev/null | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " Log file not found"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- 10) Optional: modem status script ---
|
||||||
|
if [ -x "/usr/local/bin/modem-status-at.sh" ]; then
|
||||||
|
echo "--- 10) modem-status-at.sh (registration, signal) ---"
|
||||||
|
/usr/local/bin/modem-status-at.sh 2>&1 | head -30 | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo "--- 10) modem-status-at.sh ---"
|
||||||
|
echo " Not installed"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=============================================="
|
||||||
|
echo " End troubleshoot – copy this output to share"
|
||||||
|
echo "=============================================="
|
||||||
|
echo ""
|
||||||
|
echo "Quick fixes to try:"
|
||||||
|
echo " - ttyUSB is regular file: rm $AT_PORT && mknod $AT_PORT c 188 1 && chmod 660 $AT_PORT && chown root:dialout $AT_PORT"
|
||||||
|
echo " - Modem in Mode 41 (7127): power-cycle modem or reboot; need Mode 40 (7126) for AT"
|
||||||
|
echo " - AT not OK: set AT_PORT in $CONFIG to the port that showed OK above (e.g. /dev/ttyUSB0)"
|
||||||
|
echo " - Restart connection: service 5g-router restart or /usr/local/bin/connect-5g.sh"
|
||||||
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