diff --git a/.gitignore b/.gitignore index 63f8b75..04e4e56 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ Thumbs.db # Backup files *.bak *.backup + +# Web GUI – sensitive / generated +web/data/users.json +web/data/*.db +web/data/*.db-journal diff --git a/5G_MODEM_TROUBLESHOOTING.md b/5G_MODEM_TROUBLESHOOTING.md index 5ba7117..6aee74c 100644 --- a/5G_MODEM_TROUBLESHOOTING.md +++ b/5G_MODEM_TROUBLESHOOTING.md @@ -97,10 +97,13 @@ echo -e 'AT+CFUN=1,1\r' > /dev/ttyUSB1 ### 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` -2. Configure the interface manually +1. Get the IP from the modem via **`AT+CGPADDR=1`** +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 @@ -169,16 +172,68 @@ ping -c 3 8.8.8.8 ## 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:** -1. Check modem is in Mode 40: `lsusb | grep 7126` -2. If in Mode 41 (7127), AT commands won't work - need physical access to switch back -3. Try different ports: ttyUSB0, ttyUSB1, ttyUSB2 +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. Check modem is in Mode 40: `lsusb | grep 7126` +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----` @@ -196,11 +251,15 @@ chown root:dialout /dev/ttyUSB3 **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 @@ -237,6 +296,7 @@ lsmod | grep -E '(cdc|qmi)' | Path | Purpose | |------|---------| | `/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 | | `/var/log/5g-router.log` | Connection log | | `/etc/iptables/rules.v4` | Firewall/NAT rules | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4b0dfa2 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index a7a6aa5..176a96a 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,14 @@ The 5G modem is fully operational with CYTA Cyprus SIM card. | File | Description | |------|-------------| -| [README.md](README.md) | This file - overview and quick start | -| [5G_MODEM_TROUBLESHOOTING.md](5G_MODEM_TROUBLESHOOTING.md) | Complete modem configuration reference | -| [configure_fm350_5g.sh](configure_fm350_5g.sh) | Configuration script for the modem | +| [README.md](README.md) | This file – overview and reference | +| [docs/QUICKSTART.md](docs/QUICKSTART.md) | **Quick start** – clone → install script → config → start | +| [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 @@ -42,7 +47,21 @@ Internet (CYTA 5G) 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 @@ -154,20 +173,35 @@ rc-status # - 5g-router (connection script) ``` -## Files on Device +## Files on Device (after install) | 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/iptables-restore` | Firewall restore service | -| `/etc/iptables/rules.v4` | Saved firewall rules | -| `/etc/dnsmasq.conf` | DHCP configuration | -| `/etc/network/interfaces` | Network configuration | +| `/etc/iptables/rules.v4` | Saved firewall rules (from repo `etc/iptables/rules.v4`) | +| `/etc/dnsmasq.conf` | DHCP configuration (if using dnsmasq) | | `/var/log/5g-router.log` | Connection log | +| `/var/log/speedtest-5g.log` | Optional speedtest log | ## 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 1. Check modem is in Mode 40: `lsusb | grep 7126` @@ -193,20 +227,31 @@ service 5g-router restart ## Verification Commands ```bash -# Check modem -lsusb | grep -i fibocom +# Status script (modem, interface, default route, last speedtest) +/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 route show - -# Test connectivity ping -c 3 8.8.8.8 -ping -c 3 google.com - -# Check NAT rules iptables -t nat -L -n -v - -# View logs 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 +``` diff --git a/REVISION b/REVISION new file mode 100644 index 0000000..a866948 --- /dev/null +++ b/REVISION @@ -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 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..c63f183 --- /dev/null +++ b/TODO.md @@ -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.* diff --git a/configure_fm350_5g.sh b/configure_fm350_5g.sh index 5ecbb73..beba52d 100755 --- a/configure_fm350_5g.sh +++ b/configure_fm350_5g.sh @@ -1,17 +1,20 @@ #!/bin/bash # Fibocom FM350-GL 5G Modem Configuration Script -# For Alpine Linux with CYTA Cyprus SIM card -# -# This script configures the 5G modem and establishes a connection +# For Alpine Linux. Supports config file /etc/5g-router.conf # Usage: ./configure_fm350_5g.sh +# Rev: 1 (see REVISION in repo root) set -e -# Configuration -AT_PORT="/dev/ttyUSB1" -APN="internet" -WAN_IF="eth1" -LAN_IF="eth0.100" +# Defaults (overridden by /etc/5g-router.conf if present) +AT_PORT="${AT_PORT:-/dev/ttyUSB1}" +APN="${APN:-internet}" +WAN_IF="${WAN_IF:-eth1}" +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 RED='\033[0;31m' @@ -62,7 +65,11 @@ check_modem() { if [ "$usb_id" = "0e8d:7127" ]; then 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 if [ ! -c "$AT_PORT" ]; then @@ -101,6 +108,11 @@ check_signal() { else log_info "Signal strength: $csq (good)" 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 log_warn "Could not read signal strength" fi diff --git a/docs/AT_COMMANDS_REFERENCE.md b/docs/AT_COMMANDS_REFERENCE.md new file mode 100644 index 0000000..c62ab6c --- /dev/null +++ b/docs/AT_COMMANDS_REFERENCE.md @@ -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",""` | 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**. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..36aaf9b --- /dev/null +++ b/docs/DEPLOY.md @@ -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). diff --git a/docs/DNS.md b/docs/DNS.md new file mode 100644 index 0000000..b97d6e5 --- /dev/null +++ b/docs/DNS.md @@ -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`. diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 0000000..91c34a0 --- /dev/null +++ b/docs/QUICKSTART.md @@ -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). diff --git a/docs/WEBGUI.md b/docs/WEBGUI.md new file mode 100644 index 0000000..ce85f3a --- /dev/null +++ b/docs/WEBGUI.md @@ -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://: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. diff --git a/etc/5g-router.conf.example b/etc/5g-router.conf.example new file mode 100644 index 0000000..d192d12 --- /dev/null +++ b/etc/5g-router.conf.example @@ -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" diff --git a/etc/init.d/5g-router b/etc/init.d/5g-router new file mode 100644 index 0000000..8dc592e --- /dev/null +++ b/etc/init.d/5g-router @@ -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 +} diff --git a/etc/init.d/5g-webgui b/etc/init.d/5g-webgui new file mode 100644 index 0000000..6d8343e --- /dev/null +++ b/etc/init.d/5g-webgui @@ -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 +} diff --git a/etc/iptables/rules.v4 b/etc/iptables/rules.v4 new file mode 100644 index 0000000..93380ef --- /dev/null +++ b/etc/iptables/rules.v4 @@ -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 diff --git a/scripts/connect-5g.sh b/scripts/connect-5g.sh new file mode 100644 index 0000000..80e501b --- /dev/null +++ b/scripts/connect-5g.sh @@ -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 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..44ef8a7 --- /dev/null +++ b/scripts/deploy.sh @@ -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://:5000" +echo " SSH: ssh $TARGET" diff --git a/scripts/diag-at-port.sh b/scripts/diag-at-port.sh new file mode 100644 index 0000000..b2cd7f1 --- /dev/null +++ b/scripts/diag-at-port.sh @@ -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 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 ===" diff --git a/scripts/diag-modem-up.sh b/scripts/diag-modem-up.sh new file mode 100644 index 0000000..7a74ebe --- /dev/null +++ b/scripts/diag-modem-up.sh @@ -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" diff --git a/scripts/healthcheck-5g.sh b/scripts/healthcheck-5g.sh new file mode 100644 index 0000000..80a1836 --- /dev/null +++ b/scripts/healthcheck-5g.sh @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..c26edbf --- /dev/null +++ b/scripts/install.sh @@ -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://: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" diff --git a/scripts/modem-status-at.sh b/scripts/modem-status-at.sh new file mode 100644 index 0000000..4de5887 --- /dev/null +++ b/scripts/modem-status-at.sh @@ -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' diff --git a/scripts/rotate-5g-log.sh b/scripts/rotate-5g-log.sh new file mode 100644 index 0000000..c63d8ef --- /dev/null +++ b/scripts/rotate-5g-log.sh @@ -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 diff --git a/scripts/speedtest-5g.sh b/scripts/speedtest-5g.sh new file mode 100644 index 0000000..66ff255 --- /dev/null +++ b/scripts/speedtest-5g.sh @@ -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 diff --git a/scripts/ssh-diag-modem.sh b/scripts/ssh-diag-modem.sh new file mode 100755 index 0000000..b89cac3 --- /dev/null +++ b/scripts/ssh-diag-modem.sh @@ -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 +" diff --git a/scripts/status-5g.sh b/scripts/status-5g.sh new file mode 100644 index 0000000..196f55b --- /dev/null +++ b/scripts/status-5g.sh @@ -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 diff --git a/scripts/troubleshoot-5g.sh b/scripts/troubleshoot-5g.sh new file mode 100644 index 0000000..ff9da12 --- /dev/null +++ b/scripts/troubleshoot-5g.sh @@ -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" diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..e8722ef --- /dev/null +++ b/web/app.py @@ -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/") +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/", 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/", 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/", 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/", 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) diff --git a/web/auth_manager.py b/web/auth_manager.py new file mode 100644 index 0000000..e4b316d --- /dev/null +++ b/web/auth_manager.py @@ -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 diff --git a/web/data/.gitkeep b/web/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/db.py b/web/db.py new file mode 100644 index 0000000..91f53e9 --- /dev/null +++ b/web/db.py @@ -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 diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..8a45a7c --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1 @@ +Flask>=2.3.0 diff --git a/web/run.sh b/web/run.sh new file mode 100644 index 0000000..72d6887 --- /dev/null +++ b/web/run.sh @@ -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) +" diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..710b985 --- /dev/null +++ b/web/static/app.js @@ -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 '
' + escapeHtml(value == null || value === '' ? '–' : String(value)) + '
'; + } + + async function loadStatus() { + grid.innerHTML = 'Loading…'; + 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 = 'No modem AT data (check AT port).'; + } + } catch (e) { + grid.innerHTML = '' + escapeHtml(e.message) + ''; + 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 = 'Loading…'; + try { + const data = await api('/firewall'); + const rules = data.rules || []; + if (rules.length === 0) { + tableBody.innerHTML = 'No rules. Add one below.'; + return; + } + tableBody.innerHTML = rules.map(r => { + const delBtn = ''; + return '' + escapeHtml(r.table_name) + '' + escapeHtml(r.rule_line) + '' + (r.enabled ? 'yes' : 'no') + '' + delBtn + ''; + }).join(''); + tableBody.querySelectorAll('[data-delete-id]').forEach(btn => { + btn.addEventListener('click', () => deleteRule(parseInt(btn.getAttribute('data-delete-id'), 10))); + }); + } catch (e) { + tableBody.innerHTML = '' + escapeHtml(e.message) + ''; + } + } + + 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 = 'Loading…'; + try { + const data = await api('/routes'); + const routes = data.routes || []; + if (routes.length === 0) { + tableBody.innerHTML = 'No routes. Add one below.'; + return; + } + tableBody.innerHTML = routes.map(r => { + const delBtn = ''; + return '' + escapeHtml(r.destination) + '' + escapeHtml(r.gateway) + '' + escapeHtml(r.dev) + '' + (r.metric != null ? r.metric : '') + '' + (r.enabled ? 'yes' : 'no') + '' + delBtn + ''; + }).join(''); + tableBody.querySelectorAll('[data-delete-id]').forEach(btn => { + btn.addEventListener('click', () => deleteRoute(parseInt(btn.getAttribute('data-delete-id'), 10))); + }); + } catch (e) { + tableBody.innerHTML = '' + escapeHtml(e.message) + ''; + } + } + + 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 => '
' + escapeHtml(l) + '
').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' ? '' : ''; + return '' + escapeHtml(u.username) + '' + escapeHtml(u.role) + '' + del + ''; + }).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(); + } +})(); diff --git a/web/static/login.html b/web/static/login.html new file mode 100644 index 0000000..968b728 --- /dev/null +++ b/web/static/login.html @@ -0,0 +1,68 @@ + + + + + + Login – Alpine 5G Router + + + + + + + diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..ace3ade --- /dev/null +++ b/web/static/style.css @@ -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; +} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..0c5eb6c --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,39 @@ + + + + + + {% block title %}Alpine 5G Router{% endblock %} + + + +
+
+

Alpine 5G Router

+
+ {{ user.username if user else '–' }} + {% if user %}({{ user.role }}){% endif %} + Log out +
+
+ + + +
+ {% block content %}{% endblock %} +
+
+ + {% block scripts %}{% endblock %} + + diff --git a/web/templates/config.html b/web/templates/config.html new file mode 100644 index 0000000..a81ee2d --- /dev/null +++ b/web/templates/config.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}Config – Alpine 5G Router{% endblock %} +{% block content %} +

Router config (/etc/5g-router.conf)

+

Edit and save. Keys and values only (one per line).

+ +
+ + +
+
+{% endblock %} diff --git a/web/templates/firewall.html b/web/templates/firewall.html new file mode 100644 index 0000000..567cdf9 --- /dev/null +++ b/web/templates/firewall.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Firewall – Alpine 5G Router{% endblock %} +{% block content %} +

Firewall rules (iptables)

+

Rules are stored in SQLite. Add rules below, then click Apply to write /etc/iptables/rules.v4 and run iptables-restore.

+ +
+ + + + + +
TableRuleEnabledActions
+
+ +
+

Add rule

+
+ + + +
+
+ +
+ +
+
+{% endblock %} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..0596475 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,68 @@ + + + + + + Login – Alpine 5G Router + + + + + + + diff --git a/web/templates/logs.html b/web/templates/logs.html new file mode 100644 index 0000000..839a102 --- /dev/null +++ b/web/templates/logs.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}Logs – Alpine 5G Router{% endblock %} +{% block content %} +

Logs

+
+ + +
+
Select a log above.
+{% endblock %} diff --git a/web/templates/restart.html b/web/templates/restart.html new file mode 100644 index 0000000..51b58aa --- /dev/null +++ b/web/templates/restart.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Restart 5G – Alpine 5G Router{% endblock %} +{% block content %} +

Restart 5G connection

+

Run the connection script to bring up or refresh the 5G link.

+ +
+{% endblock %} diff --git a/web/templates/routes.html b/web/templates/routes.html new file mode 100644 index 0000000..5b72f28 --- /dev/null +++ b/web/templates/routes.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Routes – Alpine 5G Router{% endblock %} +{% block content %} +

Static routes (SQLite)

+

Routes are stored in SQLite. Add below, then click Apply to run ip route add for each. Existing system routes are not removed.

+ +
+ + + + + +
DestinationGatewayDevMetricEnabledActions
+
+ +
+

Add route

+
+ + + + + +
+
+ +
+ + +
+
+ +

Current system routes (ip route show)

+
+{% endblock %} diff --git a/web/templates/status.html b/web/templates/status.html new file mode 100644 index 0000000..3b25c7c --- /dev/null +++ b/web/templates/status.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Status – Alpine 5G Router{% endblock %} +{% block content %} +

Modem & network status

+
+ +

Modem details (AT)

+
+ +

Speedtest

+

Run speedtest via 5G (modem) or WAN (eth0).

+
+ + +
+
+
+ +
+ +
+{% endblock %} diff --git a/web/templates/users.html b/web/templates/users.html new file mode 100644 index 0000000..c1456f4 --- /dev/null +++ b/web/templates/users.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Users – Alpine 5G Router{% endblock %} +{% block content %} +

Users (admin only)

+
+ + + + + +
UsernameRoleActions
+
+
+ +
+
+{% endblock %}