diff --git a/.gitignore b/.gitignore index a2a77b49..245d6fa4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ yarn-error.log includes/config.php rootCA.pem vendor +.env diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 00000000..bef3c6ae --- /dev/null +++ b/api/auth.py @@ -0,0 +1,24 @@ +import os +from fastapi.security.api_key import APIKeyHeader +from fastapi import Security, HTTPException +from starlette.status import HTTP_403_FORBIDDEN +from dotenv import load_dotenv + +load_dotenv() + +apikey=os.getenv('RASPAP_API_KEY') +#if env not set, set the api key to "insecure" +if apikey == None: + apikey = "insecure" + +print(apikey) +api_key_header = APIKeyHeader(name="access_token", auto_error=False) + +async def get_api_key(api_key_header: str = Security(api_key_header)): + if api_key_header ==apikey: + return api_key_header + else: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, detail="403: Unauthorized" + ) + diff --git a/api/main.py b/api/main.py new file mode 100644 index 00000000..8e98647d --- /dev/null +++ b/api/main.py @@ -0,0 +1,156 @@ +from fastapi import FastAPI, Depends +from fastapi.security.api_key import APIKey +import auth + +import json + +import modules.system as system +import modules.ap as ap +import modules.client as client +import modules.dns as dns +import modules.dhcp as dhcp +import modules.ddns as ddns +import modules.firewall as firewall +import modules.networking as networking +import modules.openvpn as openvpn +import modules.wireguard as wireguard + + +tags_metadata = [ +] +app = FastAPI( + title="API for RaspAP", + openapi_tags=tags_metadata, + version="0.0.1", + license_info={ + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html", + } +) + +@app.get("/system", tags=["system"]) +async def get_system(api_key: APIKey = Depends(auth.get_api_key)): + return{ +'hostname': system.hostname(), +'uptime': system.uptime(), +'systime': system.systime(), +'usedMemory': system.usedMemory(), +'processorCount': system.processorCount(), +'LoadAvg1Min': system.LoadAvg1Min(), +'systemLoadPercentage': system.systemLoadPercentage(), +'systemTemperature': system.systemTemperature(), +'hostapdStatus': system.hostapdStatus(), +'operatingSystem': system.operatingSystem(), +'kernelVersion': system.kernelVersion(), +'rpiRevision': system.rpiRevision() +} + +@app.get("/ap", tags=["accesspoint/hotspot"]) +async def get_ap(api_key: APIKey = Depends(auth.get_api_key)): + return{ +'driver': ap.driver(), +'ctrl_interface': ap.ctrl_interface(), +'ctrl_interface_group': ap.ctrl_interface_group(), +'auth_algs': ap.auth_algs(), +'wpa_key_mgmt': ap.wpa_key_mgmt(), +'beacon_int': ap.beacon_int(), +'ssid': ap.ssid(), +'channel': ap.channel(), +'hw_mode': ap.hw_mode(), +'ieee80211n': ap.ieee80211n(), +'wpa_passphrase': ap.wpa_passphrase(), +'interface': ap.interface(), +'wpa': ap.wpa(), +'wpa_pairwise': ap.wpa_pairwise(), +'country_code': ap.country_code(), +'ignore_broadcast_ssid': ap.ignore_broadcast_ssid() +} + +@app.get("/clients/{wireless_interface}", tags=["Clients"]) +async def get_clients(wireless_interface, api_key: APIKey = Depends(auth.get_api_key)): + return{ +'active_clients_amount': client.get_active_clients_amount(wireless_interface), +'active_clients': json.loads(client.get_active_clients(wireless_interface)) +} + +@app.get("/dhcp", tags=["DHCP"]) +async def get_dhcp(api_key: APIKey = Depends(auth.get_api_key)): + return{ +'range_start': dhcp.range_start(), +'range_end': dhcp.range_end(), +'range_subnet_mask': dhcp.range_subnet_mask(), +'range_lease_time': dhcp.range_lease_time(), +'range_gateway': dhcp.range_gateway(), +'range_nameservers': dhcp.range_nameservers() +} + +@app.get("/dns/domains", tags=["DNS"]) +async def get_domains(api_key: APIKey = Depends(auth.get_api_key)): + return{ +'domains': json.loads(dns.adblockdomains()) +} + +@app.get("/dns/hostnames", tags=["DNS"]) +async def get_hostnames(api_key: APIKey = Depends(auth.get_api_key)): + return{ +'hostnames': json.loads(dns.adblockhostnames()) +} + +@app.get("/dns/upstream", tags=["DNS"]) +async def get_upstream(api_key: APIKey = Depends(auth.get_api_key)): + return{ +'upstream_nameserver': dns.upstream_nameserver() +} + +@app.get("/dns/logs", tags=["DNS"]) +async def get_dnsmasq_logs(api_key: APIKey = Depends(auth.get_api_key)): + return(dns.dnsmasq_logs()) + + +@app.get("/ddns", tags=["DDNS"]) +async def get_ddns(api_key: APIKey = Depends(auth.get_api_key)): + return{ +'use': ddns.use(), +'method': ddns.method(), +'protocol': ddns.protocol(), +'server': ddns.server(), +'login': ddns.login(), +'password': ddns.password(), +'domain': ddns.domain() +} + +@app.get("/firewall", tags=["Firewall"]) +async def get_firewall(api_key: APIKey = Depends(auth.get_api_key)): + return json.loads(firewall.firewall_rules()) + +@app.get("/networking", tags=["Networking"]) +async def get_networking(api_key: APIKey = Depends(auth.get_api_key)): + return{ +'interfaces': json.loads(networking.interfaces()), +'throughput': json.loads(networking.throughput()) +} + +@app.get("/openvpn", tags=["OpenVPN"]) +async def get_openvpn(api_key: APIKey = Depends(auth.get_api_key)): + return{ +'client_configs': openvpn.client_configs(), +'client_config_names': openvpn.client_config_names(), +'client_config_active': openvpn.client_config_active(), +'client_login_names': openvpn.client_login_names(), +'client_login_active': openvpn.client_login_active() +} + +@app.get("/openvpn/{config}", tags=["OpenVPN"]) +async def client_config_list(config, api_key: APIKey = Depends(auth.get_api_key)): + return{ +'client_config': openvpn.client_config_list(config) +} + +@app.get("/wireguard", tags=["WireGuard"]) +async def get_wireguard(api_key: APIKey = Depends(auth.get_api_key)): + return{ +'client_configs': wireguard.configs(), +'client_config_names': wireguard.client_config_names(), +'client_config_active': wireguard.client_config_active() +} + diff --git a/api/modules/ap.py b/api/modules/ap.py new file mode 100644 index 00000000..25dffdad --- /dev/null +++ b/api/modules/ap.py @@ -0,0 +1,64 @@ +import subprocess +import json + +def driver(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep driver= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def ctrl_interface(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep ctrl_interface= | cut -d'=' -f2 | head -1", shell=True, capture_output=True, text=True).stdout.strip() + +def ctrl_interface_group(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep ctrl_interface_group= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def auth_algs(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep auth_algs= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def wpa_key_mgmt(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep wpa_key_mgmt= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def beacon_int(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep beacon_int= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def ssid(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep ssid= | cut -d'=' -f2 | head -1", shell=True, capture_output=True, text=True).stdout.strip() + +def channel(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep channel= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def hw_mode(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep hw_mode= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def ieee80211n(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep ieee80211n= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def wpa_passphrase(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep wpa_passphrase= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def interface(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep interface= | cut -d'=' -f2 | head -1", shell=True, capture_output=True, text=True).stdout.strip() + +def wpa(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep wpa= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def wpa_pairwise(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep wpa_pairwise= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def country_code(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep country_code= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def ignore_broadcast_ssid(): + return subprocess.run("cat /etc/hostapd/hostapd.conf | grep ignore_broadcast_ssid= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def logging(): + log_output = subprocess.run(f"cat /tmp/hostapd.log", shell=True, capture_output=True, text=True).stdout.strip() + logs = {} + + for line in log_output.split('\n'): + parts = line.split(': ') + if len(parts) >= 2: + interface, message = parts[0], parts[1] + if interface not in logs: + logs[interface] = [] + logs[interface].append(message) + + return json.dumps(logs, indent=2) \ No newline at end of file diff --git a/api/modules/client.py b/api/modules/client.py new file mode 100644 index 00000000..63caf40d --- /dev/null +++ b/api/modules/client.py @@ -0,0 +1,38 @@ +import subprocess +import json + +def get_active_clients_amount(interface): + arp_output = subprocess.run(['arp', '-i', interface], capture_output=True, text=True) + mac_addresses = arp_output.stdout.splitlines() + + if mac_addresses: + grep_pattern = '|'.join(mac_addresses) + output = subprocess.run(['grep', '-iwE', grep_pattern, '/var/lib/misc/dnsmasq.leases'], capture_output=True, text=True) + return len(output.stdout.splitlines()) + else: + return 0 + +def get_active_clients(interface): + arp_output = subprocess.run(['arp', '-i', interface], capture_output=True, text=True) + arp_mac_addresses = set(line.split()[2] for line in arp_output.stdout.splitlines()[1:]) + + dnsmasq_output = subprocess.run(['cat', '/var/lib/misc/dnsmasq.leases'], capture_output=True, text=True) + active_clients = [] + + for line in dnsmasq_output.stdout.splitlines(): + fields = line.split() + mac_address = fields[1] + + if mac_address in arp_mac_addresses: + client_data = { + "timestamp": int(fields[0]), + "mac_address": fields[1], + "ip_address": fields[2], + "hostname": fields[3], + "client_id": fields[4], + } + active_clients.append(client_data) + + json_output = json.dumps(active_clients, indent=2) + return json_output + diff --git a/api/modules/ddns.py b/api/modules/ddns.py new file mode 100644 index 00000000..e7ab3ebf --- /dev/null +++ b/api/modules/ddns.py @@ -0,0 +1,24 @@ +import subprocess + +def use(): + return subprocess.run("cat /etc/ddclient.conf | grep use= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def method(): + #get the contents of the line below "use=" + return subprocess.run("awk '/^use=/ {getline; print}' /etc/ddclient.conf | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def protocol(): + return subprocess.run("cat /etc/ddclient.conf | grep protocol= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def server(): + return subprocess.run("cat /etc/ddclient.conf | grep server= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def login(): + return subprocess.run("cat /etc/ddclient.conf | grep login= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def password(): + return subprocess.run("cat /etc/ddclient.conf | grep password= | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def domain(): + #get the contents of the line below "password=" + return subprocess.run("awk '/^password=/ {getline; print}' /etc/ddclient.conf", shell=True, capture_output=True, text=True).stdout.strip() \ No newline at end of file diff --git a/api/modules/dhcp.py b/api/modules/dhcp.py new file mode 100644 index 00000000..887bb29b --- /dev/null +++ b/api/modules/dhcp.py @@ -0,0 +1,30 @@ +import subprocess +import json + +def range_start(): + return subprocess.run("cat /etc/dnsmasq.d/090_wlan0.conf |grep dhcp-range= |cut -d'=' -f2| cut -d',' -f1", shell=True, capture_output=True, text=True).stdout.strip() + +def range_end(): + return subprocess.run("cat /etc/dnsmasq.d/090_wlan0.conf |grep dhcp-range= |cut -d'=' -f2| cut -d',' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def range_subnet_mask(): + return subprocess.run("cat /etc/dnsmasq.d/090_wlan0.conf |grep dhcp-range= |cut -d'=' -f2| cut -d',' -f3", shell=True, capture_output=True, text=True).stdout.strip() + +def range_lease_time(): + return subprocess.run("cat /etc/dnsmasq.d/090_wlan0.conf |grep dhcp-range= |cut -d'=' -f2| cut -d',' -f4", shell=True, capture_output=True, text=True).stdout.strip() + +def range_gateway(): + return subprocess.run("cat /etc/dhcpcd.conf | grep routers | cut -d'=' -f2", shell=True, capture_output=True, text=True).stdout.strip() + +def range_nameservers(): + output = subprocess.run("cat /etc/dhcpcd.conf", shell=True, capture_output=True, text=True).stdout.strip() + + nameservers = [] + + lines = output.split('\n') + for line in lines: + if "static domain_name_server" in line: + servers = line.split('=')[1].strip().split() + nameservers.extend(servers) + + return nameservers \ No newline at end of file diff --git a/api/modules/dns.py b/api/modules/dns.py new file mode 100644 index 00000000..17fb4d1e --- /dev/null +++ b/api/modules/dns.py @@ -0,0 +1,38 @@ +import subprocess +import json + +def adblockdomains(): + output = subprocess.run("cat /etc/raspap/adblock/domains.txt", shell=True, capture_output=True, text=True).stdout.strip() + domains =output.split('\n') + domainlist=[] + for domain in domains: + if domain.startswith('#') or domain=="": + continue + domainlist.append(domain.split('=/')[1]) + return domainlist + +def adblockhostnames(): + output = subprocess.run("cat /etc/raspap/adblock/hostnames.txt", shell=True, capture_output=True, text=True).stdout.strip() + hostnames = output.split('\n') + hostnamelist=[] + for hostname in hostnames: + if hostname.startswith('#') or hostname=="": + continue + hostnamelist.append(hostname.replace('0.0.0.0 ','')) + return hostnamelist + +def upstream_nameserver(): + return subprocess.run("awk '/nameserver/ {print $2}' /run/dnsmasq/resolv.conf", shell=True, capture_output=True, text=True).stdout.strip() + +def dnsmasq_logs(): + output = subprocess.run("cat /var/log/dnsmasq.log", shell=True, capture_output=True, text=True).stdout.strip() + log_entries = [] + for line in output.split("\n"): + fields = line.split(" ") + log_dict = { + 'timestamp': ' '.join(fields[:3]), + 'process': fields[3][:-1], # Remove the trailing colon + 'message': ' '.join(fields[4:]), + } + log_entries.append(log_dict) + return log_entries \ No newline at end of file diff --git a/api/modules/firewall.py b/api/modules/firewall.py new file mode 100644 index 00000000..004b9e4a --- /dev/null +++ b/api/modules/firewall.py @@ -0,0 +1,4 @@ +import subprocess + +def firewall_rules(): + return subprocess.run("cat /etc/raspap/networking/firewall/iptables_rules.json", shell=True, capture_output=True, text=True).stdout.strip() \ No newline at end of file diff --git a/api/modules/networking.py b/api/modules/networking.py new file mode 100644 index 00000000..989e0eb4 --- /dev/null +++ b/api/modules/networking.py @@ -0,0 +1,68 @@ +import psutil +import json + +def throughput(): + interface_info = {} + + # Get network interfaces + interfaces = psutil.net_if_stats() + + for interface, stats in interfaces.items(): + if interface.startswith("lo") or interface.startswith("docker"): + # Skip loopback and docker interface + continue + + try: + # Get network traffic statistics + traffic_stats = psutil.net_io_counters(pernic=True)[interface] + rx_packets = traffic_stats[1] + rx_bytes = traffic_stats[0] + tx_packets = traffic_stats[3] + tx_bytes = traffic_stats[4] + + interface_info[interface] = { + "RX_packets": rx_packets, + "RX_bytes": rx_bytes, + "TX_packets": tx_packets, + "TX_bytes": tx_bytes + } + except KeyError: + # Handle the case where network interface statistics are not available + pass + + return json.dumps(interface_info, indent=2) + +def interfaces(): + interface_info = {} + + # Get network interfaces + interfaces = psutil.net_if_addrs() + + for interface, addrs in interfaces.items(): + if interface.startswith("lo") or interface.startswith("docker"): + # Skip loopback and docker interface + continue + + ip_address = None + netmask = None + mac_address = None + + for addr in addrs: + if addr.family == 2: # AF_INET corresponds to the integer value 2 + # IPv4 address + ip_address = addr.address + netmask = addr.netmask + + # Get MAC address + for addr in psutil.net_if_addrs().get(interface, []): + if addr.family == psutil.AF_LINK: + mac_address = addr.address + + interface_info[interface] = { + "IP_address": ip_address, + "Netmask": netmask, + "MAC_address": mac_address + } + return json.dumps(interface_info, indent=2) + +#TODO: migrate to vnstat, to lose psutil dependency \ No newline at end of file diff --git a/api/modules/openvpn.py b/api/modules/openvpn.py new file mode 100644 index 00000000..9000cfc1 --- /dev/null +++ b/api/modules/openvpn.py @@ -0,0 +1,41 @@ +import subprocess + +def client_configs(): + return subprocess.run("find /etc/openvpn/client/ -type f | wc -l", shell=True, capture_output=True, text=True).stdout.strip() + +def client_config_names(): + config_names_list = [] + output = subprocess.run('''ls /etc/openvpn/client/ | grep -v "^client.conf$"''', shell=True, capture_output=True, text=True).stdout.strip() + lines = output.split("\n") + for client in lines: + if "_client" in client: + config_names_dict ={'config':client} + config_names_list.append(config_names_dict) + return config_names_list + +def client_login_names(): + config_names_list = [] + output = subprocess.run('''ls /etc/openvpn/client/ | grep -v "^client.conf$"''', shell=True, capture_output=True, text=True).stdout.strip() + lines = output.split("\n") + for client in lines: + if "_login" in client: + config_names_dict ={'login':client} + config_names_list.append(config_names_dict) + return config_names_list + +def client_config_active(): + output = subprocess.run('''ls -al /etc/openvpn/client/ | grep "client.conf -"''', shell=True, capture_output=True, text=True).stdout.strip() + active_config = output.split("/etc/openvpn/client/") + return(active_config[1]) + +def client_login_active(): + output = subprocess.run('''ls -al /etc/openvpn/client/ | grep "login.conf -"''', shell=True, capture_output=True, text=True).stdout.strip() + active_config = output.split("/etc/openvpn/client/") + return(active_config[1]) + +def client_config_list(client_config): + output = subprocess.run(["cat", f"/etc/openvpn/client/{client_config}"], capture_output=True, text=True).stdout.strip() + return output.split('\n') + +#TODO: where is the logfile?? +#TODO: is service connected? diff --git a/api/modules/system.py b/api/modules/system.py new file mode 100644 index 00000000..82367779 --- /dev/null +++ b/api/modules/system.py @@ -0,0 +1,86 @@ +import subprocess + +revisions = { + '0002': 'Model B Revision 1.0', + '0003': 'Model B Revision 1.0 + ECN0001', + '0004': 'Model B Revision 2.0 (256 MB)', + '0005': 'Model B Revision 2.0 (256 MB)', + '0006': 'Model B Revision 2.0 (256 MB)', + '0007': 'Model A', + '0008': 'Model A', + '0009': 'Model A', + '000d': 'Model B Revision 2.0 (512 MB)', + '000e': 'Model B Revision 2.0 (512 MB)', + '000f': 'Model B Revision 2.0 (512 MB)', + '0010': 'Model B+', + '0013': 'Model B+', + '0011': 'Compute Module', + '0012': 'Model A+', + 'a01041': 'a01041', + 'a21041': 'a21041', + '900092': 'PiZero 1.2', + '900093': 'PiZero 1.3', + '9000c1': 'PiZero W', + 'a02082': 'Pi 3 Model B', + 'a22082': 'Pi 3 Model B', + 'a32082': 'Pi 3 Model B', + 'a52082': 'Pi 3 Model B', + 'a020d3': 'Pi 3 Model B+', + 'a220a0': 'Compute Module 3', + 'a020a0': 'Compute Module 3', + 'a02100': 'Compute Module 3+', + 'a03111': 'Model 4B Revision 1.1 (1 GB)', + 'b03111': 'Model 4B Revision 1.1 (2 GB)', + 'c03111': 'Model 4B Revision 1.1 (4 GB)', + 'c03111': 'Model 4B Revision 1.1 (4 GB)', + 'a03140': 'Compute Module 4 (1 GB)', + 'b03140': 'Compute Module 4 (2 GB)', + 'c03140': 'Compute Module 4 (4 GB)', + 'd03140': 'Compute Module 4 (8 GB)', + 'c04170': 'Pi 5 (4 GB)', + 'd04170': 'Pi 5 (8 GB)' +} + +def hostname(): + return subprocess.run("hostname", shell=True, capture_output=True, text=True).stdout.strip() + +def uptime(): + return subprocess.run("uptime -p", shell=True, capture_output=True, text=True).stdout.strip() + +def systime(): + return subprocess.run("date", shell=True, capture_output=True, text=True).stdout.strip() + +def usedMemory(): + return round(float(subprocess.run("free -m | awk 'NR==2{total=$2 ; used=$3 } END { print used/total*100}'", shell=True, capture_output=True, text=True).stdout.strip()),2) + +def processorCount(): + return int(subprocess.run("nproc --all", shell=True, capture_output=True, text=True).stdout.strip()) + +def LoadAvg1Min(): + return round(float(subprocess.run("awk '{print $1}' /proc/loadavg", shell=True, capture_output=True, text=True).stdout.strip()),2) + +def systemLoadPercentage(): + return round((float(LoadAvg1Min())*100)/float(processorCount()),2) + +def systemTemperature(): + try: + output = subprocess.run("cat /sys/class/thermal/thermal_zone0/temp", shell=True, capture_output=True, text=True).stdout.strip() + return round(float(output)/1000,2) + except ValueError: + return 0 + +def hostapdStatus(): + return int(subprocess.run("pidof hostapd | wc -l", shell=True, capture_output=True, text=True).stdout.strip()) + +def operatingSystem(): + return subprocess.run('''grep PRETTY_NAME /etc/os-release | cut -d= -f2- | sed 's/"//g' ''', shell=True, capture_output=True, text=True).stdout.strip() + +def kernelVersion(): + return subprocess.run("uname -r", shell=True, capture_output=True, text=True).stdout.strip() + +def rpiRevision(): + output = subprocess.run("grep Revision /proc/cpuinfo | awk '{print $3}'", shell=True, capture_output=True, text=True).stdout.strip() + try: + return revisions[output] + except KeyError: + return 'Unknown Device' \ No newline at end of file diff --git a/api/modules/wireguard.py b/api/modules/wireguard.py new file mode 100644 index 00000000..36eaa2b6 --- /dev/null +++ b/api/modules/wireguard.py @@ -0,0 +1,23 @@ +import subprocess +import re + +def configs(): + #ignore symlinks, because wg0.conf is in production the main config, but in insiders it is a symlink + return subprocess.run("find /etc/wireguard/ -type f | wc -l", shell=True, capture_output=True, text=True).stdout.strip() + +def client_config_names(): + config_names_list = [] + output = subprocess.run('''ls /etc/wireguard/ | grep -v "^wg0.conf$"''', shell=True, capture_output=True, text=True).stdout.strip() + lines = output.split("\n") + for client in lines: + config_names_dict ={'config':client} + config_names_list.append(config_names_dict) + return config_names_list + +def client_config_active(): + output = subprocess.run('''ls -al /etc/wireguard/ | grep "wg0.conf -"''', shell=True, capture_output=True, text=True).stdout.strip() + active_config = output.split("/etc/wireguard/") + return(active_config[1]) + +#TODO: where is the logfile?? +#TODO: is service connected? diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 00000000..7c82a713 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.109.0 +uvicorn==0.25.0 +psutil==5.9.8 +python-dotenv==1.0.1 + diff --git a/app/js/custom.js b/app/js/custom.js index 8cbeeecf..febf7609 100644 --- a/app/js/custom.js +++ b/app/js/custom.js @@ -122,6 +122,10 @@ $(document).on("click", "#gen_wpa_passphrase", function(e) { $('#txtwpapassphrase').val(genPassword(63)); }); +$(document).on("click", "#gen_apikey", function(e) { + $('#txtapikey').val(genPassword(32).toLowerCase()); +}); + $(document).on("click", "#js-clearhostapd-log", function(e) { var csrfToken = $('meta[name=csrf_token]').attr('content'); $.post('ajax/logging/clearlog.php?',{'logfile':'/tmp/hostapd.log', 'csrf_token': csrfToken},function(data){ diff --git a/composer.json b/composer.json index ed4f5e8f..ad52f66b 100644 --- a/composer.json +++ b/composer.json @@ -13,12 +13,15 @@ } ], "require": { - "php": "^7.0" + "php": "^8.2", + "phpoption/phpoption": "^1.9", + "ext-mbstring": "*" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2.0", "phpcompatibility/php-compatibility": "^9.3.5", - "squizlabs/php_codesniffer": "^3.5.5" + "squizlabs/php_codesniffer": "^3.9.0", + "ext-simplexml": "*" }, "scripts": { "lint": "parallel-lint . --exclude vendor", diff --git a/config/config.php b/config/config.php index a252a556..2950632d 100755 --- a/config/config.php +++ b/config/config.php @@ -4,6 +4,7 @@ define('RASPI_BRAND_TEXT', 'RaspAP'); define('RASPI_CONFIG', '/etc/raspap'); define('RASPI_CONFIG_NETWORK', RASPI_CONFIG.'/networking/defaults.json'); define('RASPI_CONFIG_PROVIDERS', 'config/vpn-providers.json'); +define('RASPI_CONFIG_API', RASPI_CONFIG.'/api'); define('RASPI_ADMIN_DETAILS', RASPI_CONFIG.'/raspap.auth'); define('RASPI_WIFI_AP_INTERFACE', 'wlan0'); define('RASPI_CACHE_PATH', sys_get_temp_dir() . '/raspap'); @@ -59,6 +60,7 @@ define('RASPI_CHANGETHEME_ENABLED', true); define('RASPI_VNSTAT_ENABLED', true); define('RASPI_SYSTEM_ENABLED', true); define('RASPI_MONITOR_ENABLED', false); +define('RASPI_RESTAPI_ENABLED', false); // Locale settings define('LOCALE_ROOT', 'locale'); diff --git a/includes/defaults.php b/includes/defaults.php index 6f359c23..5e4f8fa0 100755 --- a/includes/defaults.php +++ b/includes/defaults.php @@ -9,6 +9,7 @@ $defaults = [ 'RASPI_VERSION' => '3.0.9', 'RASPI_CONFIG_NETWORK' => RASPI_CONFIG.'/networking/defaults.json', 'RASPI_CONFIG_PROVIDERS' => 'config/vpn-providers.json', + 'RASPI_CONFIG_API' => RASPI_CONFIG.'/api', 'RASPI_ADMIN_DETAILS' => RASPI_CONFIG.'/raspap.auth', 'RASPI_WIFI_AP_INTERFACE' => 'wlan0', 'RASPI_CACHE_PATH' => sys_get_temp_dir() . '/raspap', diff --git a/includes/page_actions.php b/includes/page_actions.php index ed64a33e..de4a1cd0 100755 --- a/includes/page_actions.php +++ b/includes/page_actions.php @@ -45,6 +45,9 @@ case "/system_info": DisplaySystem($extraFooterScripts); break; + case "/restapi_conf": + DisplayRestAPI(); + break; case "/about": DisplayAbout(); break; diff --git a/includes/restapi.php b/includes/restapi.php new file mode 100644 index 00000000..68207b2b --- /dev/null +++ b/includes/restapi.php @@ -0,0 +1,94 @@ +load(); + + // set defaults + $apiKey = $_ENV['RASPAP_API_KEY']; + + if (!RASPI_MONITOR_ENABLED) { + if (isset($_POST['SaveAPIsettings'])) { + if (isset($_POST['txtapikey'])) { + $apiKey = trim($_POST['txtapikey']); + if (strlen($apiKey) == 0) { + $status->addMessage('Please enter a valid API key', 'danger'); + } else { + $return = saveAPISettings($status, $apiKey, $dotenv); + $status->addMessage('Restarting restapi.service', 'info'); + exec('sudo /bin/systemctl stop restapi.service', $return); + sleep(1); + exec('sudo /bin/systemctl start restapi.service', $return); + } + } + } elseif (isset($_POST['StartRestAPIservice'])) { + $status->addMessage('Attempting to start restapi.service', 'info'); + exec('sudo /bin/systemctl start restapi.service', $return); + foreach ($return as $line) { + $status->addMessage($line, 'info'); + } + } elseif (isset($_POST['StopRestAPIservice'])) { + $status->addMessage('Attempting to stop restapi.service', 'info'); + exec('sudo /bin/systemctl stop restapi.service', $return); + foreach ($return as $line) { + $status->addMessage($line, 'info'); + } + } + } + exec("ps aux | grep -v grep | grep uvicorn", $output, $return); + $serviceStatus = !empty($output) ? "up" : "down"; + + exec("sudo systemctl status restapi.service", $output, $return); + array_shift($output); + $serviceLog = implode("\n", $output); + + if ($serviceStatus == "up") { + $docUrl = getDocUrl(); + $faicon = ""; + $docMsg = sprintf(_("RestAPI docs are accessible here%s"),$docUrl, $faicon); + } + + echo renderTemplate("restapi", compact( + "status", + "apiKey", + "serviceStatus", + "serviceLog", + "docMsg" + )); +} + +/** + * Saves RestAPI settings + * + * @param object status + * @param object dotenv + * @param string $apiKey + */ +function saveAPISettings($status, $apiKey, $dotenv) +{ + $status->addMessage('Saving API key', 'info'); + $dotenv->set('RASPAP_API_KEY', $apiKey); + return $status; +} + +// Returns a url for fastapi's automatic docs +function getDocUrl() +{ + $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://'; + $server_name = $_SERVER['SERVER_NAME']; + $port = 8081; + $url = $protocol . $server_name .':'. $port . '/docs'; + return $url; +} + diff --git a/includes/sidebar.php b/includes/sidebar.php index 34545fc0..b83b8764 100755 --- a/includes/sidebar.php +++ b/includes/sidebar.php @@ -80,12 +80,17 @@ + + + - diff --git a/index.php b/index.php index d658fedb..83855b02 100755 --- a/index.php +++ b/index.php @@ -47,6 +47,7 @@ require_once 'includes/about.php'; require_once 'includes/openvpn.php'; require_once 'includes/wireguard.php'; require_once 'includes/provider.php'; +require_once 'includes/restapi.php'; require_once 'includes/torproxy.php'; initializeApp(); diff --git a/installers/common.sh b/installers/common.sh index 383705be..aa0db26a 100755 --- a/installers/common.sh +++ b/installers/common.sh @@ -57,6 +57,7 @@ function _install_raspap() { _configure_networking _prompt_install_adblock _prompt_install_openvpn + _prompt_install_restapi _install_extra_features _prompt_install_wireguard _prompt_install_vpn_providers @@ -502,6 +503,24 @@ function _prompt_install_openvpn() { fi } +# Prompt to install restapi +function _prompt_install_restapi() { + _install_log "Configure RestAPI" + echo -n "Install and enable RestAPI? [Y/n]: " + if [ "$assume_yes" == 0 ]; then + read answer < /dev/tty + if [ "$answer" != "${answer#[Nn]}" ]; then + _install_status 0 "(Skipped)" + else + _install_restapi + fi + elif [ "$restapi_option" == 1 ]; then + _install_restapi + else + echo "(Skipped)" + fi +} + # Prompt to install WireGuard function _prompt_install_wireguard() { _install_log "Configure WireGuard support" @@ -562,6 +581,33 @@ function _create_openvpn_scripts() { _install_status 0 } +# Install and enable RestAPI configuration option +function _install_restapi() { + _install_log "Installing and enabling RestAPI" + sudo mv "$webroot_dir/api" "$raspap_dir/api" || _install_status 1 "Unable to move api folder" + + if ! command -v python3 &> /dev/null; then + echo "Python is not installed. Installing Python..." + sudo apt update + sudo apt install -y python3 python3-pip + echo "Python installed successfully." + else + echo "Python is already installed." + sudo apt install python3-pip -y + + fi + python3 -m pip install -r "$raspap_dir/api/requirements.txt" --break-system-packages || _install_status 1 " Unable to install pip modules" + + echo "Moving restapi systemd unit control file to /lib/systemd/system/" + sudo mv $webroot_dir/installers/restapi.service /lib/systemd/system/ || _install_status 1 "Unable to move restapi.service file" + sudo systemctl daemon-reload + sudo systemctl enable restapi.service || _install_status 1 "Failed to enable restapi.service" + echo "Enabling RestAPI management option" + sudo sed -i "s/\('RASPI_RESTAPI_ENABLED', \)false/\1true/g" "$webroot_dir/includes/config.php" || _install_status 1 "Unable to modify config.php" + + _install_status 0 +} + # Fetches latest files from github to webroot function _download_latest_files() { _install_log "Cloning latest files from GitHub" diff --git a/installers/raspap.sudoers b/installers/raspap.sudoers index 99de3b1d..b87fdb80 100644 --- a/installers/raspap.sudoers +++ b/installers/raspap.sudoers @@ -26,6 +26,11 @@ www-data ALL=(ALL) NOPASSWD:/bin/systemctl start openvpn-client@client www-data ALL=(ALL) NOPASSWD:/bin/systemctl enable openvpn-client@client www-data ALL=(ALL) NOPASSWD:/bin/systemctl stop openvpn-client@client www-data ALL=(ALL) NOPASSWD:/bin/systemctl disable openvpn-client@client +www-data ALL=(ALL) NOPASSWD:/bin/systemctl start restapi.service +www-data ALL=(ALL) NOPASSWD:/bin/systemctl stop restapi.service +www-data ALL=(ALL) NOPASSWD:/bin/systemctl status restapi.service +www-data ALL=(ALL) NOPASSWD:/bin/touch /etc/raspap/api/.env +www-data ALL=(ALL) NOPASSWD:/bin/mv /tmp/.env /etc/raspap/api/.env www-data ALL=(ALL) NOPASSWD:/bin/mv /tmp/ovpn/* /etc/openvpn/client/*.conf www-data ALL=(ALL) NOPASSWD:/usr/bin/ln -s /etc/openvpn/client/*.conf /etc/openvpn/client/*.conf www-data ALL=(ALL) NOPASSWD:/bin/rm /etc/openvpn/client/*.conf diff --git a/installers/raspbian.sh b/installers/raspbian.sh index 6217f57e..0626faab 100755 --- a/installers/raspbian.sh +++ b/installers/raspbian.sh @@ -40,6 +40,7 @@ OPTIONS: -y, --yes, --assume-yes Assumes "yes" as an answer to all prompts -c, --cert, --certificate Installs an SSL certificate for lighttpd -o, --openvpn Used with -y, --yes, sets OpenVPN install option (0=no install) +-s, --rest, --restapi Used with -y, --yes, sets RestAPI install option (0=no install) -a, --adblock Used with -y, --yes, sets Adblock install option (0=no install) -w, --wireguard Used with -y, --yes, sets WireGuard install option (0=no install) -e, --provider Used with -y, --yes, sets the VPN provider install option @@ -94,6 +95,7 @@ function _parse_params() { upgrade=0 update=0 ovpn_option=1 + restapi_option=1 adblock_option=1 wg_option=1 insiders=0 @@ -111,6 +113,10 @@ function _parse_params() { ovpn_option="$2" shift ;; + -s|--rest|--restapi) + restapi_option="$2" + shift + ;; -a|--adblock) adblock_option="$2" shift diff --git a/installers/restapi.service b/installers/restapi.service new file mode 100644 index 00000000..92026322 --- /dev/null +++ b/installers/restapi.service @@ -0,0 +1,16 @@ +[Unit] +Description=raspap-restapi +After=network.target + +[Service] +User=pi +WorkingDirectory=/etc/raspap/api +LimitNOFILE=4096 +ExecStart=/usr/bin/python3 -m uvicorn main:app --host 0.0.0.0 --port 8081 +ExecStop=/bin/kill -HUP ${MAINPID} +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target + diff --git a/locale/en_US/LC_MESSAGES/messages.mo b/locale/en_US/LC_MESSAGES/messages.mo index 10bd1cf2..62b44fcd 100644 Binary files a/locale/en_US/LC_MESSAGES/messages.mo and b/locale/en_US/LC_MESSAGES/messages.mo differ diff --git a/locale/en_US/LC_MESSAGES/messages.po b/locale/en_US/LC_MESSAGES/messages.po index 40058366..976b3799 100644 --- a/locale/en_US/LC_MESSAGES/messages.po +++ b/locale/en_US/LC_MESSAGES/messages.po @@ -1528,3 +1528,35 @@ msgstr "RaspAP Exception" msgid "An exception occurred" msgstr "An exception occurred" +#: includes/restapi.php + +msgid "RestAPI" +msgstr "RestAPI" + +msgid "RestAPI settings" +msgstr "RestAPI settings" + +msgid "Start RestAPI service" +msgstr "Start RestAPI service" + +msgid "Stop RestAPI service" +msgstr "Stop RestAPI service" + +msgid "API Key" +msgstr "API Key" + +msgid "Saving API key" +msgstr "Saving API key" + +msgid "RestAPI status" +msgstr "RestAPI status" + +msgid "Current restapi.service status is displayed below." +msgstr "Current restapi.service status is displayed below." + +msgid "RestAPI docs are accessible here%s" +msgstr "RestAPI docs are accessible here%s" + +msgid "Restarting restapi.service" +msgstr "Restarting restapi.service" + diff --git a/src/RaspAP/DotEnv/DotEnv.php b/src/RaspAP/DotEnv/DotEnv.php new file mode 100644 index 00000000..1e8397a1 --- /dev/null +++ b/src/RaspAP/DotEnv/DotEnv.php @@ -0,0 +1,90 @@ + + * @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE + */ + +declare(strict_types=1); + +namespace RaspAP\DotEnv; + +class DotEnv +{ + protected $envFile; + protected $data = []; + + public function __construct($envFile = RASPI_CONFIG_API. '/.env') + { + $this->envFile = $envFile; + } + + public function load() + { + if (!file_exists($this->envFile)) { + $this->createEnv(); + } + + if (file_exists($this->envFile)) { + $this->data = parse_ini_file($this->envFile); + foreach ($this->data as $key => $value) { + if (!getenv($key)) { + putenv("$key=$value"); + $_ENV[$key] = $value; + } + } + } else { + throw new Exception(".env file '{$this->envFile}' not found."); + } + } + + public function set($key, $value) + { + $this->data[$key] = $value; + putenv("$key=$value"); + $this->store($key, $value); + } + + public function get($key) + { + return getenv($key); + } + + public function getAll() + { + return $this->data; + } + + public function unset($key) + { + unset($_ENV[$key]); + return $this; + } + + private function store($key, $value) + { + $content = file_get_contents($this->envFile); + $content = preg_replace("/^$key=.*/m", "$key=$value", $content, 1, $count); + if ($count === 0) { + // if key doesn't exist, append it + $content .= "$key=$value\n"; + } + file_put_contents("/tmp/.env", $content); + system('sudo mv /tmp/.env '.$this->envFile, $result); + if ($result !== 0) { + throw new Exception("Unable to move .env file: ". $this->envFile); + } + } + + protected function createEnv() + { + exec('sudo touch '. escapeshellarg($this->envFile), $output, $result); + if ($result !== 0) { + throw new Exception("Unable to create .env file: ". $this->envFile); + } + } +} + diff --git a/templates/restapi.php b/templates/restapi.php new file mode 100644 index 00000000..fc1d1437 --- /dev/null +++ b/templates/restapi.php @@ -0,0 +1,52 @@ + + + " /> + + " /> + + " /> + + + + +
+
+
+
+
+
+ +
+
+ +
+
+
+
+ showMessages(); ?> +
+ + + + + +
+ + +
+ + +
+
+ +
+
+
+ + diff --git a/templates/restapi/general.php b/templates/restapi/general.php new file mode 100644 index 00000000..07f12406 --- /dev/null +++ b/templates/restapi/general.php @@ -0,0 +1,27 @@ +
+

+
+
+
+
+ +
+
+
+
+ +
+ +
+ +
+
+ +
+
+
+
+
+
+
+ diff --git a/templates/restapi/status.php b/templates/restapi/status.php new file mode 100644 index 00000000..4cfacb15 --- /dev/null +++ b/templates/restapi/status.php @@ -0,0 +1,11 @@ + +
+

+

restapi.service status is displayed below."); ?>

+
+
+ +
+
+
+