diff --git a/.gitignore b/.gitignore index 6b179f5..26ed7b0 100755 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,7 @@ /frontend/coverage /backend/db/ -/backend/db/firegex.db -/backend/db/firegex.db-journal +/backend/db/** /backend/modules/cppqueue docker-compose.yml diff --git a/backend/app.py b/backend/app.py index 064e54b..1b06af7 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,74 +1,42 @@ -from base64 import b64decode -import sqlite3, uvicorn, sys, secrets, re -import httpx, websockets, os, asyncio -from typing import List, Union -from fastapi import FastAPI, HTTPException, WebSocket, Depends -from pydantic import BaseModel, BaseSettings -from fastapi.responses import FileResponse, StreamingResponse +import uvicorn, secrets, utils +import os, asyncio +from typing import List +from fastapi import FastAPI, HTTPException, Depends, APIRouter from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from jose import JWTError, jwt +from jose import jwt from passlib.context import CryptContext from fastapi_socketio import SocketManager -from modules import SQLite, FirewallManager -from modules.firewall import STATUS +from modules import SQLite from modules.firegex import FiregexTables -from utils import get_interfaces, ip_parse, refactor_name, gen_service_id - -ON_DOCKER = len(sys.argv) > 1 and sys.argv[1] == "DOCKER" -DEBUG = len(sys.argv) > 1 and sys.argv[1] == "DEBUG" +from utils import API_VERSION, FIREGEX_PORT, JWT_ALGORITHM, get_interfaces, refresh_frontend, DEBUG +from utils.loader import frontend_deploy, load_routers +from utils.models import ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel # DB init -if not os.path.exists("db"): os.mkdir("db") db = SQLite('db/firegex.db') -firewall = FirewallManager(db) -class Settings(BaseSettings): - JWT_ALGORITHM: str = "HS256" - REACT_BUILD_DIR: str = "../frontend/build/" if not ON_DOCKER else "frontend/" - REACT_HTML_PATH: str = os.path.join(REACT_BUILD_DIR,"index.html") - VERSION = "1.5.0" - - -settings = Settings() oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/login", auto_error=False) crypto = CryptContext(schemes=["bcrypt"], deprecated="auto") app = FastAPI(debug=DEBUG, redoc_url=None) -sio = SocketManager(app, "/sock", socketio_path="") +utils.socketio = SocketManager(app, "/sock", socketio_path="") def APP_STATUS(): return "init" if db.get("password") is None else "run" def JWT_SECRET(): return db.get("secret") -async def refresh_frontend(): - await sio.emit("update","Refresh") - -@sio.on("update") +@utils.socketio.on("update") async def updater(): pass -@app.on_event("startup") -async def startup_event(): - db.init() - await firewall.init() - await refresh_frontend() - if not JWT_SECRET(): db.put("secret", secrets.token_hex(32)) - -@app.on_event("shutdown") -async def shutdown_event(): - db.backup() - await firewall.close() - db.disconnect() - db.restore() - def create_access_token(data: dict): to_encode = data.copy() - encoded_jwt = jwt.encode(to_encode, JWT_SECRET(), algorithm=settings.JWT_ALGORITHM) + encoded_jwt = jwt.encode(to_encode, JWT_SECRET(), algorithm=JWT_ALGORITHM) return encoded_jwt async def check_login(token: str = Depends(oauth2_scheme)): if not token: return False try: - payload = jwt.decode(token, JWT_SECRET(), algorithms=[settings.JWT_ALGORITHM]) + payload = jwt.decode(token, JWT_SECRET(), algorithms=[JWT_ALGORITHM]) logged_in: bool = payload.get("logged_in") except Exception: return False @@ -77,16 +45,13 @@ async def check_login(token: str = Depends(oauth2_scheme)): async def is_loggined(auth: bool = Depends(check_login)): if not auth: raise HTTPException( - status_code=401, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) + status_code=401, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) return True -class StatusModel(BaseModel): - status: str - loggined: bool - version: str +api = APIRouter(prefix="/api", dependencies=[Depends(is_loggined)]) @app.get("/api/status", response_model=StatusModel) async def get_app_status(auth: bool = Depends(check_login)): @@ -94,16 +59,9 @@ async def get_app_status(auth: bool = Depends(check_login)): return { "status": APP_STATUS(), "loggined": auth, - "version": settings.VERSION + "version": API_VERSION } -class PasswordForm(BaseModel): - password: str - -class PasswordChangeForm(BaseModel): - password: str - expire: bool - @app.post("/api/login") async def login_api(form: OAuth2PasswordRequestForm = Depends()): """Get a login token to use the firegex api""" @@ -115,12 +73,19 @@ async def login_api(form: OAuth2PasswordRequestForm = Depends()): return {"access_token": create_access_token({"logged_in": True}), "token_type": "bearer"} raise HTTPException(406,"Wrong password!") -class ChangePasswordModel(BaseModel): - status: str - access_token: Union[str,None] +@app.post('/api/set-password', response_model=ChangePasswordModel) +async def set_password(form: PasswordForm): + """Set the password of firegex""" + if APP_STATUS() != "init": raise HTTPException(status_code=400) + if form.password == "": + return {"status":"Cannot insert an empty password!"} + hash_psw = crypto.hash(form.password) + db.put("password",hash_psw) + await refresh_frontend() + return {"status":"ok", "access_token": create_access_token({"logged_in": True})} -@app.post('/api/change-password', response_model=ChangePasswordModel) -async def change_password(form: PasswordChangeForm, auth: bool = Depends(is_loggined)): +@api.post('/change-password', response_model=ChangePasswordModel) +async def change_password(form: PasswordChangeForm): """Change the password of firegex""" if APP_STATUS() != "run": raise HTTPException(status_code=400) @@ -135,306 +100,39 @@ async def change_password(form: PasswordChangeForm, auth: bool = Depends(is_logg return {"status":"ok", "access_token": create_access_token({"logged_in": True})} -@app.post('/api/set-password', response_model=ChangePasswordModel) -async def set_password(form: PasswordForm): - """Set the password of firegex""" - if APP_STATUS() != "init": raise HTTPException(status_code=400) - if form.password == "": - return {"status":"Cannot insert an empty password!"} - hash_psw = crypto.hash(form.password) - db.put("password",hash_psw) - await refresh_frontend() - return {"status":"ok", "access_token": create_access_token({"logged_in": True})} - -class GeneralStatModel(BaseModel): - closed:int - regexes: int - services: int - -@app.get('/api/general-stats', response_model=GeneralStatModel) -async def get_general_stats(auth: bool = Depends(is_loggined)): - """Get firegex general status about services""" - return db.query(""" - SELECT - (SELECT COALESCE(SUM(blocked_packets),0) FROM regexes) closed, - (SELECT COUNT(*) FROM regexes) regexes, - (SELECT COUNT(*) FROM services) services - """)[0] - -class ServiceModel(BaseModel): - status: str - service_id: str - port: int - name: str - proto: str - ip_int: str - n_regex: int - n_packets: int - -@app.get('/api/services', response_model=List[ServiceModel]) -async def get_service_list(auth: bool = Depends(is_loggined)): - """Get the list of existent firegex services""" - return db.query(""" - SELECT - s.service_id service_id, - s.status status, - s.port port, - s.name name, - s.proto proto, - s.ip_int ip_int, - COUNT(r.regex_id) n_regex, - COALESCE(SUM(r.blocked_packets),0) n_packets - FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id - GROUP BY s.service_id; - """) - -@app.get('/api/service/{service_id}', response_model=ServiceModel) -async def get_service_by_id(service_id: str, auth: bool = Depends(is_loggined)): - """Get info about a specific service using his id""" - res = db.query(""" - SELECT - s.service_id service_id, - s.status status, - s.port port, - s.name name, - s.proto proto, - s.ip_int ip_int, - COUNT(r.regex_id) n_regex, - COALESCE(SUM(r.blocked_packets),0) n_packets - FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id - WHERE s.service_id = ? GROUP BY s.service_id; - """, service_id) - if len(res) == 0: raise HTTPException(status_code=400, detail="This service does not exists!") - return res[0] - -class StatusMessageModel(BaseModel): - status:str - -@app.get('/api/service/{service_id}/stop', response_model=StatusMessageModel) -async def service_stop(service_id: str, auth: bool = Depends(is_loggined)): - """Request the stop of a specific service""" - await firewall.get(service_id).next(STATUS.STOP) - await refresh_frontend() - return {'status': 'ok'} - -@app.get('/api/service/{service_id}/start', response_model=StatusMessageModel) -async def service_start(service_id: str, auth: bool = Depends(is_loggined)): - """Request the start of a specific service""" - await firewall.get(service_id).next(STATUS.ACTIVE) - await refresh_frontend() - return {'status': 'ok'} - -@app.get('/api/service/{service_id}/delete', response_model=StatusMessageModel) -async def service_delete(service_id: str, auth: bool = Depends(is_loggined)): - """Request the deletion of a specific service""" - db.query('DELETE FROM services WHERE service_id = ?;', service_id) - db.query('DELETE FROM regexes WHERE service_id = ?;', service_id) - await firewall.remove(service_id) - await refresh_frontend() - return {'status': 'ok'} - -class RenameForm(BaseModel): - name:str - -@app.post('/api/service/{service_id}/rename', response_model=StatusMessageModel) -async def service_rename(service_id: str, form: RenameForm, auth: bool = Depends(is_loggined)): - """Request to change the name of a specific service""" - form.name = refactor_name(form.name) - if not form.name: return {'status': 'The name cannot be empty!'} - try: - db.query('UPDATE services SET name=? WHERE service_id = ?;', form.name, service_id) - except sqlite3.IntegrityError: - return {'status': 'This name is already used'} - await refresh_frontend() - return {'status': 'ok'} - -class RegexModel(BaseModel): - regex:str - mode:str - id:int - service_id:str - is_blacklist: bool - n_packets:int - is_case_sensitive:bool - active:bool - -@app.get('/api/service/{service_id}/regexes', response_model=List[RegexModel]) -async def get_service_regexe_list(service_id: str, auth: bool = Depends(is_loggined)): - """Get the list of the regexes of a service""" - return db.query(""" - SELECT - regex, mode, regex_id `id`, service_id, is_blacklist, - blocked_packets n_packets, is_case_sensitive, active - FROM regexes WHERE service_id = ?; - """, service_id) - -@app.get('/api/regex/{regex_id}', response_model=RegexModel) -async def get_regex_by_id(regex_id: int, auth: bool = Depends(is_loggined)): - """Get regex info using his id""" - res = db.query(""" - SELECT - regex, mode, regex_id `id`, service_id, is_blacklist, - blocked_packets n_packets, is_case_sensitive, active - FROM regexes WHERE `id` = ?; - """, regex_id) - if len(res) == 0: raise HTTPException(status_code=400, detail="This regex does not exists!") - return res[0] - -@app.get('/api/regex/{regex_id}/delete', response_model=StatusMessageModel) -async def regex_delete(regex_id: int, auth: bool = Depends(is_loggined)): - """Delete a regex using his id""" - res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id) - if len(res) != 0: - db.query('DELETE FROM regexes WHERE regex_id = ?;', regex_id) - await firewall.get(res[0]["service_id"]).update_filters() - await refresh_frontend() - - return {'status': 'ok'} - -@app.get('/api/regex/{regex_id}/enable', response_model=StatusMessageModel) -async def regex_enable(regex_id: int, auth: bool = Depends(is_loggined)): - """Request the enabling of a regex""" - res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id) - if len(res) != 0: - db.query('UPDATE regexes SET active=1 WHERE regex_id = ?;', regex_id) - await firewall.get(res[0]["service_id"]).update_filters() - await refresh_frontend() - return {'status': 'ok'} - -@app.get('/api/regex/{regex_id}/disable', response_model=StatusMessageModel) -async def regex_disable(regex_id: int, auth: bool = Depends(is_loggined)): - """Request the deactivation of a regex""" - res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id) - if len(res) != 0: - db.query('UPDATE regexes SET active=0 WHERE regex_id = ?;', regex_id) - await firewall.get(res[0]["service_id"]).update_filters() - await refresh_frontend() - return {'status': 'ok'} - -class RegexAddForm(BaseModel): - service_id: str - regex: str - mode: str - active: Union[bool,None] - is_blacklist: bool - is_case_sensitive: bool - -@app.post('/api/regexes/add', response_model=StatusMessageModel) -async def add_new_regex(form: RegexAddForm, auth: bool = Depends(is_loggined)): - """Add a new regex""" - try: - re.compile(b64decode(form.regex)) - except Exception: - return {"status":"Invalid regex"} - try: - db.query("INSERT INTO regexes (service_id, regex, is_blacklist, mode, is_case_sensitive, active ) VALUES (?, ?, ?, ?, ?, ?);", - form.service_id, form.regex, form.is_blacklist, form.mode, form.is_case_sensitive, True if form.active is None else form.active ) - except sqlite3.IntegrityError: - return {'status': 'An identical regex already exists'} - - await firewall.get(form.service_id).update_filters() - await refresh_frontend() - return {'status': 'ok'} - -class ServiceAddForm(BaseModel): - name: str - port: int - proto: str - ip_int: str - -class ServiceAddResponse(BaseModel): - status:str - service_id: Union[None,str] - -@app.post('/api/services/add', response_model=ServiceAddResponse) -async def add_new_service(form: ServiceAddForm, auth: bool = Depends(is_loggined)): - """Add a new service""" - try: - form.ip_int = ip_parse(form.ip_int) - except ValueError: - return {"status":"Invalid address"} - if form.proto not in ["tcp", "udp"]: - return {"status":"Invalid protocol"} - srv_id = None - try: - srv_id = gen_service_id(db) - db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int) VALUES (?, ?, ?, ?, ?, ?)", - srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int) - except sqlite3.IntegrityError: - return {'status': 'This type of service already exists'} - await firewall.reload() - await refresh_frontend() - return {'status': 'ok', 'service_id': srv_id} - -class IpInterface(BaseModel): - addr: str - name: str - -@app.get('/api/interfaces', response_model=List[IpInterface]) -async def get_ip_interfaces(auth: bool = Depends(is_loggined)): +@api.get('/interfaces', response_model=List[IpInterface]) +async def get_ip_interfaces(): """Get a list of ip and ip6 interfaces""" return get_interfaces() -class ResetRequest(BaseModel): - delete:bool +#Routers Loader +reset, startup, shutdown = load_routers(api) -@app.post('/api/reset', response_model=StatusMessageModel) -async def reset_firegex(form: ResetRequest, auth: bool = Depends(is_loggined)): +@app.on_event("startup") +async def startup_event(): + db.init() + await startup() + if not JWT_SECRET(): db.put("secret", secrets.token_hex(32)) + await refresh_frontend() + +@app.on_event("shutdown") +async def shutdown_event(): + await shutdown() + db.disconnect() + +@api.post('/reset', response_model=StatusMessageModel) +async def reset_firegex(form: ResetRequest): """Reset firegex nftables rules and optionally all the database""" - if not form.delete: - db.backup() - await firewall.close() - FiregexTables().reset() if form.delete: db.delete() db.init() db.put("secret", secrets.token_hex(32)) - else: - db.restore() - await firewall.init() + await reset(form) await refresh_frontend() - return {'status': 'ok'} -async def frontend_debug_proxy(path): - httpc = httpx.AsyncClient() - req = httpc.build_request("GET",f"http://127.0.0.1:{os.getenv('F_PORT','3000')}/"+path) - resp = await httpc.send(req, stream=True) - return StreamingResponse(resp.aiter_bytes(),status_code=resp.status_code) - -async def react_deploy(path): - file_request = os.path.join(settings.REACT_BUILD_DIR, path) - if not os.path.isfile(file_request): - return FileResponse(settings.REACT_HTML_PATH, media_type='text/html') - else: - return FileResponse(file_request) - -if DEBUG: - async def forward_websocket(ws_a, ws_b): - while True: - data = await ws_a.receive_bytes() - await ws_b.send(data) - async def reverse_websocket(ws_a, ws_b): - while True: - data = await ws_b.recv() - await ws_a.send_text(data) - @app.websocket("/ws") - async def websocket_debug_proxy(ws: WebSocket): - await ws.accept() - async with websockets.connect(f"ws://127.0.0.1:{os.getenv('F_PORT','3000')}/ws") as ws_b_client: - fwd_task = asyncio.create_task(forward_websocket(ws, ws_b_client)) - rev_task = asyncio.create_task(reverse_websocket(ws, ws_b_client)) - await asyncio.gather(fwd_task, rev_task) - -@app.get("/{full_path:path}", include_in_schema=False) -async def catch_all(full_path:str): - if DEBUG: - try: - return await frontend_debug_proxy(full_path) - except Exception: - return {"details":"Frontend not started at "+f"http://127.0.0.1:{os.getenv('F_PORT','3000')}"} - else: return await react_deploy(full_path) - +app.include_router(api) +frontend_deploy(app) if __name__ == '__main__': # os.environ {PORT = Backend Port (Main Port), F_PORT = Frontend Port} @@ -442,7 +140,7 @@ if __name__ == '__main__': uvicorn.run( "app:app", host="0.0.0.0", - port=int(os.getenv("PORT","4444")), + port=FIREGEX_PORT, reload=DEBUG, access_log=DEBUG, workers=1 diff --git a/backend/modules/firegex.py b/backend/modules/firegex.py index 31feb9f..ba53a7a 100644 --- a/backend/modules/firegex.py +++ b/backend/modules/firegex.py @@ -1,5 +1,5 @@ from typing import Dict, List, Set -from utils import ip_parse, ip_family +from utils import ip_parse, ip_family, run_func from modules.sqlite import Service import re, os, asyncio import traceback, nftables @@ -182,8 +182,7 @@ class RegexFilter: async def update(self): if self.update_func: - if asyncio.iscoroutinefunction(self.update_func): await self.update_func(self) - else: self.update_func(self) + await run_func(self.update_func, self) class FiregexInterceptor: diff --git a/backend/modules/sqlite.py b/backend/modules/sqlite.py index 47362b4..4bf38a3 100644 --- a/backend/modules/sqlite.py +++ b/backend/modules/sqlite.py @@ -4,42 +4,20 @@ from hashlib import md5 import base64 class SQLite(): - def __init__(self, db_name: str) -> None: + def __init__(self, db_name: str, schema:dict = None) -> None: self.conn: Union[None, sqlite3.Connection] = None self.cur = None self.db_name = db_name self.__backup = None - self.schema = { - 'services': { - 'service_id': 'VARCHAR(100) PRIMARY KEY', - 'status': 'VARCHAR(100) NOT NULL', - 'port': 'INT NOT NULL CHECK(port > 0 and port < 65536)', - 'name': 'VARCHAR(100) NOT NULL UNIQUE', - 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))', - 'ip_int': 'VARCHAR(100) NOT NULL', - }, - 'regexes': { - 'regex': 'TEXT NOT NULL', - 'mode': 'VARCHAR(1) NOT NULL', - 'service_id': 'VARCHAR(100) NOT NULL', - 'is_blacklist': 'BOOLEAN NOT NULL CHECK (is_blacklist IN (0, 1))', - 'blocked_packets': 'INTEGER UNSIGNED NOT NULL DEFAULT 0', - 'regex_id': 'INTEGER PRIMARY KEY', - 'is_case_sensitive' : 'BOOLEAN NOT NULL CHECK (is_case_sensitive IN (0, 1))', - 'active' : 'BOOLEAN NOT NULL CHECK (active IN (0, 1)) DEFAULT 1', - 'FOREIGN KEY (service_id)':'REFERENCES services (service_id)', - }, - 'QUERY':[ - "CREATE UNIQUE INDEX IF NOT EXISTS unique_services ON services (port, ip_int, proto);", - "CREATE UNIQUE INDEX IF NOT EXISTS unique_regex_service ON regexes (regex,service_id,is_blacklist,mode,is_case_sensitive);" - ] - } + self.schema = {} if schema is None else schema self.DB_VER = md5(json.dumps(self.schema).encode()).hexdigest() def connect(self) -> None: try: self.conn = sqlite3.connect(self.db_name, check_same_thread = False) except Exception: + path_name = os.path.dirname(self.db_name) + if not os.path.exists(path_name): os.makedirs(path_name) with open(self.db_name, 'x'): pass self.conn = sqlite3.connect(self.db_name, check_same_thread = False) def dict_factory(cursor, row): diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/nfregex.py b/backend/routers/nfregex.py new file mode 100644 index 0000000..9780ce5 --- /dev/null +++ b/backend/routers/nfregex.py @@ -0,0 +1,281 @@ +from base64 import b64decode +import re +import sqlite3 +from typing import List, Union +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from modules.firegex import FiregexTables +from modules.firewall import STATUS, FirewallManager +from modules.sqlite import SQLite +from utils import gen_service_id, ip_parse, refactor_name, refresh_frontend +from utils.models import ResetRequest, StatusMessageModel + +class GeneralStatModel(BaseModel): + closed:int + regexes: int + services: int + +class ServiceModel(BaseModel): + status: str + service_id: str + port: int + name: str + proto: str + ip_int: str + n_regex: int + n_packets: int + +class RenameForm(BaseModel): + name:str + +class RegexModel(BaseModel): + regex:str + mode:str + id:int + service_id:str + is_blacklist: bool + n_packets:int + is_case_sensitive:bool + active:bool + +class RegexAddForm(BaseModel): + service_id: str + regex: str + mode: str + active: Union[bool,None] + is_blacklist: bool + is_case_sensitive: bool + +class ServiceAddForm(BaseModel): + name: str + port: int + proto: str + ip_int: str + +class ServiceAddResponse(BaseModel): + status:str + service_id: Union[None,str] + +app = APIRouter() + +async def reset(params: ResetRequest): + if not params.delete: + db.backup() + await firewall.close() + FiregexTables().reset() + if params.delete: + db.delete() + db.init() + else: + db.restore() + await firewall.init() + + +async def startup(): + db.init() + await firewall.init() + +async def shutdown(): + db.backup() + await firewall.close() + db.disconnect() + db.restore() + +db = SQLite('db/nft-regex.db', { + 'services': { + 'service_id': 'VARCHAR(100) PRIMARY KEY', + 'status': 'VARCHAR(100) NOT NULL', + 'port': 'INT NOT NULL CHECK(port > 0 and port < 65536)', + 'name': 'VARCHAR(100) NOT NULL UNIQUE', + 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))', + 'ip_int': 'VARCHAR(100) NOT NULL', + }, + 'regexes': { + 'regex': 'TEXT NOT NULL', + 'mode': 'VARCHAR(1) NOT NULL', + 'service_id': 'VARCHAR(100) NOT NULL', + 'is_blacklist': 'BOOLEAN NOT NULL CHECK (is_blacklist IN (0, 1))', + 'blocked_packets': 'INTEGER UNSIGNED NOT NULL DEFAULT 0', + 'regex_id': 'INTEGER PRIMARY KEY', + 'is_case_sensitive' : 'BOOLEAN NOT NULL CHECK (is_case_sensitive IN (0, 1))', + 'active' : 'BOOLEAN NOT NULL CHECK (active IN (0, 1)) DEFAULT 1', + 'FOREIGN KEY (service_id)':'REFERENCES services (service_id)', + }, + 'QUERY':[ + "CREATE UNIQUE INDEX IF NOT EXISTS unique_services ON services (port, ip_int, proto);", + "CREATE UNIQUE INDEX IF NOT EXISTS unique_regex_service ON regexes (regex,service_id,is_blacklist,mode,is_case_sensitive);" + ] +}) + +firewall = FirewallManager(db) + +@app.get('/stats', response_model=GeneralStatModel) +async def get_general_stats(): + """Get firegex general status about services""" + return db.query(""" + SELECT + (SELECT COALESCE(SUM(blocked_packets),0) FROM regexes) closed, + (SELECT COUNT(*) FROM regexes) regexes, + (SELECT COUNT(*) FROM services) services + """)[0] + +@app.get('/services', response_model=List[ServiceModel]) +async def get_service_list(): + """Get the list of existent firegex services""" + return db.query(""" + SELECT + s.service_id service_id, + s.status status, + s.port port, + s.name name, + s.proto proto, + s.ip_int ip_int, + COUNT(r.regex_id) n_regex, + COALESCE(SUM(r.blocked_packets),0) n_packets + FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id + GROUP BY s.service_id; + """) + +@app.get('/service/{service_id}', response_model=ServiceModel) +async def get_service_by_id(service_id: str, ): + """Get info about a specific service using his id""" + res = db.query(""" + SELECT + s.service_id service_id, + s.status status, + s.port port, + s.name name, + s.proto proto, + s.ip_int ip_int, + COUNT(r.regex_id) n_regex, + COALESCE(SUM(r.blocked_packets),0) n_packets + FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id + WHERE s.service_id = ? GROUP BY s.service_id; + """, service_id) + if len(res) == 0: raise HTTPException(status_code=400, detail="This service does not exists!") + return res[0] + +@app.get('/service/{service_id}/stop', response_model=StatusMessageModel) +async def service_stop(service_id: str, ): + """Request the stop of a specific service""" + await firewall.get(service_id).next(STATUS.STOP) + await refresh_frontend() + return {'status': 'ok'} + +@app.get('/service/{service_id}/start', response_model=StatusMessageModel) +async def service_start(service_id: str, ): + """Request the start of a specific service""" + await firewall.get(service_id).next(STATUS.ACTIVE) + await refresh_frontend() + return {'status': 'ok'} + +@app.get('/service/{service_id}/delete', response_model=StatusMessageModel) +async def service_delete(service_id: str, ): + """Request the deletion of a specific service""" + db.query('DELETE FROM services WHERE service_id = ?;', service_id) + db.query('DELETE FROM regexes WHERE service_id = ?;', service_id) + await firewall.remove(service_id) + await refresh_frontend() + return {'status': 'ok'} + +@app.post('/service/{service_id}/rename', response_model=StatusMessageModel) +async def service_rename(service_id: str, form: RenameForm, ): + """Request to change the name of a specific service""" + form.name = refactor_name(form.name) + if not form.name: return {'status': 'The name cannot be empty!'} + try: + db.query('UPDATE services SET name=? WHERE service_id = ?;', form.name, service_id) + except sqlite3.IntegrityError: + return {'status': 'This name is already used'} + await refresh_frontend() + return {'status': 'ok'} + +@app.get('/service/{service_id}/regexes', response_model=List[RegexModel]) +async def get_service_regexe_list(service_id: str, ): + """Get the list of the regexes of a service""" + return db.query(""" + SELECT + regex, mode, regex_id `id`, service_id, is_blacklist, + blocked_packets n_packets, is_case_sensitive, active + FROM regexes WHERE service_id = ?; + """, service_id) + +@app.get('/regex/{regex_id}', response_model=RegexModel) +async def get_regex_by_id(regex_id: int, ): + """Get regex info using his id""" + res = db.query(""" + SELECT + regex, mode, regex_id `id`, service_id, is_blacklist, + blocked_packets n_packets, is_case_sensitive, active + FROM regexes WHERE `id` = ?; + """, regex_id) + if len(res) == 0: raise HTTPException(status_code=400, detail="This regex does not exists!") + return res[0] + +@app.get('/regex/{regex_id}/delete', response_model=StatusMessageModel) +async def regex_delete(regex_id: int, ): + """Delete a regex using his id""" + res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id) + if len(res) != 0: + db.query('DELETE FROM regexes WHERE regex_id = ?;', regex_id) + await firewall.get(res[0]["service_id"]).update_filters() + await refresh_frontend() + + return {'status': 'ok'} + +@app.get('/regex/{regex_id}/enable', response_model=StatusMessageModel) +async def regex_enable(regex_id: int, ): + """Request the enabling of a regex""" + res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id) + if len(res) != 0: + db.query('UPDATE regexes SET active=1 WHERE regex_id = ?;', regex_id) + await firewall.get(res[0]["service_id"]).update_filters() + await refresh_frontend() + return {'status': 'ok'} + +@app.get('/regex/{regex_id}/disable', response_model=StatusMessageModel) +async def regex_disable(regex_id: int, ): + """Request the deactivation of a regex""" + res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id) + if len(res) != 0: + db.query('UPDATE regexes SET active=0 WHERE regex_id = ?;', regex_id) + await firewall.get(res[0]["service_id"]).update_filters() + await refresh_frontend() + return {'status': 'ok'} + +@app.post('/regexes/add', response_model=StatusMessageModel) +async def add_new_regex(form: RegexAddForm, ): + """Add a new regex""" + try: + re.compile(b64decode(form.regex)) + except Exception: + return {"status":"Invalid regex"} + try: + db.query("INSERT INTO regexes (service_id, regex, is_blacklist, mode, is_case_sensitive, active ) VALUES (?, ?, ?, ?, ?, ?);", + form.service_id, form.regex, form.is_blacklist, form.mode, form.is_case_sensitive, True if form.active is None else form.active ) + except sqlite3.IntegrityError: + return {'status': 'An identical regex already exists'} + + await firewall.get(form.service_id).update_filters() + await refresh_frontend() + return {'status': 'ok'} + +@app.post('/services/add', response_model=ServiceAddResponse) +async def add_new_service(form: ServiceAddForm, ): + """Add a new service""" + try: + form.ip_int = ip_parse(form.ip_int) + except ValueError: + return {"status":"Invalid address"} + if form.proto not in ["tcp", "udp"]: + return {"status":"Invalid protocol"} + srv_id = None + try: + srv_id = gen_service_id(db) + db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int) VALUES (?, ?, ?, ?, ?, ?)", + srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int) + except sqlite3.IntegrityError: + return {'status': 'This type of service already exists'} + await firewall.reload() + await refresh_frontend() + return {'status': 'ok', 'service_id': srv_id} diff --git a/backend/utils.py b/backend/utils/__init__.py similarity index 51% rename from backend/utils.py rename to backend/utils/__init__.py index 14ab4c7..726d45b 100755 --- a/backend/utils.py +++ b/backend/utils/__init__.py @@ -1,8 +1,30 @@ +import asyncio from ipaddress import ip_interface import os, socket, secrets, psutil +import sys +from fastapi_socketio import SocketManager LOCALHOST_IP = socket.gethostbyname(os.getenv("LOCALHOST_IP","127.0.0.1")) +socketio:SocketManager = None + +ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +ROUTERS_DIR = os.path.join(ROOT_DIR,"routers") +ON_DOCKER = len(sys.argv) > 1 and sys.argv[1] == "DOCKER" +DEBUG = len(sys.argv) > 1 and sys.argv[1] == "DEBUG" +FIREGEX_PORT = int(os.getenv("PORT","4444")) +JWT_ALGORITHM: str = "HS256" +API_VERSION = "2.0.0" + +async def run_func(func, *args, **kwargs): + if asyncio.iscoroutinefunction(func): + return await func(*args, **kwargs) + else: + return func(*args, **kwargs) + +async def refresh_frontend(): + await socketio.emit("update","Refresh") + def refactor_name(name:str): name = name.strip() while " " in name: name = name.replace(" "," ") @@ -15,6 +37,11 @@ def gen_service_id(db): break return res +def list_files(mypath): + from os import listdir + from os.path import isfile, join + return [f for f in listdir(mypath) if isfile(join(mypath, f))] + def ip_parse(ip:str): return str(ip_interface(ip).network) diff --git a/backend/utils/loader.py b/backend/utils/loader.py new file mode 100644 index 0000000..f10e3b3 --- /dev/null +++ b/backend/utils/loader.py @@ -0,0 +1,106 @@ + +import os, httpx, websockets +from sys import prefix +from typing import Callable, List, Union +from fastapi import APIRouter, WebSocket +import asyncio +from starlette.responses import StreamingResponse +from fastapi.responses import FileResponse +from utils import DEBUG, ON_DOCKER, ROUTERS_DIR, list_files, run_func +from utils.models import ResetRequest + +REACT_BUILD_DIR: str = "../frontend/build/" if not ON_DOCKER else "frontend/" +REACT_HTML_PATH: str = os.path.join(REACT_BUILD_DIR,"index.html") + +async def frontend_debug_proxy(path): + httpc = httpx.AsyncClient() + req = httpc.build_request("GET",f"http://127.0.0.1:{os.getenv('F_PORT','3000')}/"+path) + resp = await httpc.send(req, stream=True) + return StreamingResponse(resp.aiter_bytes(),status_code=resp.status_code) + +async def react_deploy(path): + file_request = os.path.join(REACT_BUILD_DIR, path) + if not os.path.isfile(file_request): + return FileResponse(REACT_HTML_PATH, media_type='text/html') + else: + return FileResponse(file_request) + +def frontend_deploy(app): + if DEBUG: + async def forward_websocket(ws_a, ws_b): + while True: + data = await ws_a.receive_bytes() + await ws_b.send(data) + async def reverse_websocket(ws_a, ws_b): + while True: + data = await ws_b.recv() + await ws_a.send_text(data) + @app.websocket("/ws") + async def websocket_debug_proxy(ws: WebSocket): + await ws.accept() + async with websockets.connect(f"ws://127.0.0.1:{os.getenv('F_PORT','3000')}/ws") as ws_b_client: + fwd_task = asyncio.create_task(forward_websocket(ws, ws_b_client)) + rev_task = asyncio.create_task(reverse_websocket(ws, ws_b_client)) + await asyncio.gather(fwd_task, rev_task) + + @app.get("/{full_path:path}", include_in_schema=False) + async def catch_all(full_path:str): + if DEBUG: + try: + return await frontend_debug_proxy(full_path) + except Exception: + return {"details":"Frontend not started at "+f"http://127.0.0.1:{os.getenv('F_PORT','3000')}"} + else: return await react_deploy(full_path) + +def list_routers(): + return [ele[:-3] for ele in list_files(ROUTERS_DIR) if ele != "__init__.py" and " " not in ele and ele.endswith(".py")] + +class RouterModule(): + router: Union[None, APIRouter] + reset: Union[None, Callable] + startup: Union[None, Callable] + shutdown: Union[None, Callable] + name: str + + def __init__(self, router: APIRouter, reset: Callable, startup: Callable, shutdown: Callable, name:str): + self.router = router + self.reset = reset + self.startup = startup + self.shutdown = shutdown + self.name = name + + def __repr__(self): + return f"RouterModule(router={self.router}, reset={self.reset}, startup={self.startup}, shutdown={self.shutdown})" + +def get_router_modules(): + res: List[RouterModule] = [] + for route in list_routers(): + module = getattr(__import__(f"routers.{route}"), route, None) + if module: + res.append(RouterModule( + router=getattr(module, "app", None), + reset=getattr(module, "reset", None), + startup=getattr(module, "startup", None), + shutdown=getattr(module, "shutdown", None), + name=route + )) + return res + +def load_routers(app): + resets, startups, shutdowns = [], [], [] + for router in get_router_modules(): + if router.router: + app.include_router(router.router, prefix=f"/{router.name}") + if router.reset: + resets.append(router.reset) + if router.startup: + startups.append(router.startup) + if router.shutdown: + shutdowns.append(router.shutdown) + async def reset(reset_option:ResetRequest): + for func in resets: await run_func(func, reset_option) + async def startup(): + for func in startups: await run_func(func) + async def shutdown(): + for func in shutdowns: await run_func(func) + return reset, startup, shutdown diff --git a/backend/utils/models.py b/backend/utils/models.py new file mode 100644 index 0000000..e589685 --- /dev/null +++ b/backend/utils/models.py @@ -0,0 +1,28 @@ +from typing import Union +from pydantic import BaseModel + +class StatusMessageModel(BaseModel): + status:str + +class StatusModel(BaseModel): + status: str + loggined: bool + version: str + +class PasswordForm(BaseModel): + password: str + +class PasswordChangeForm(BaseModel): + password: str + expire: bool + +class ChangePasswordModel(BaseModel): + status: str + access_token: Union[str,None] + +class IpInterface(BaseModel): + addr: str + name: str + +class ResetRequest(BaseModel): + delete:bool \ No newline at end of file diff --git a/frontend/build/asset-manifest.json b/frontend/build/asset-manifest.json index 0a512ff..c9a068e 100644 --- a/frontend/build/asset-manifest.json +++ b/frontend/build/asset-manifest.json @@ -1,13 +1,13 @@ { "files": { "main.css": "/static/css/main.08225a85.css", - "main.js": "/static/js/main.10378d73.js", + "main.js": "/static/js/main.3838510f.js", "index.html": "/index.html", "main.08225a85.css.map": "/static/css/main.08225a85.css.map", - "main.10378d73.js.map": "/static/js/main.10378d73.js.map" + "main.3838510f.js.map": "/static/js/main.3838510f.js.map" }, "entrypoints": [ "static/css/main.08225a85.css", - "static/js/main.10378d73.js" + "static/js/main.3838510f.js" ] } \ No newline at end of file diff --git a/frontend/build/index.html b/frontend/build/index.html index 47f5cb8..aa39e5b 100644 --- a/frontend/build/index.html +++ b/frontend/build/index.html @@ -1 +1 @@ -