Iptables -> NFtables

This commit is contained in:
DomySh
2022-07-19 15:17:34 +02:00
parent 139fe39130
commit a020e4311d
17 changed files with 2310 additions and 2127 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@
# testing
/frontend/coverage
/backend/db/
/backend/db/firegex.db
/backend/db/firegex.db-journal
/backend/modules/cppqueue

View File

@@ -2,7 +2,7 @@
FROM python:slim-bullseye
RUN apt-get update && apt-get -y install \
build-essential git iptables libpcre2-dev\
build-essential git python3-nftables libpcre2-dev\
libnetfilter-queue-dev libtins-dev\
libnfnetlink-dev libmnl-dev

View File

@@ -9,10 +9,9 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi_socketio import SocketManager
from ipaddress import ip_interface
from modules import SQLite, FirewallManager
from modules.firewall import STATUS
from utils import refactor_name, gen_service_id
from utils import 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"
@@ -54,8 +53,10 @@ async def startup_event():
@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()
@@ -350,11 +351,8 @@ class ServiceAddResponse(BaseModel):
@app.post('/api/services/add', response_model=ServiceAddResponse)
async def add_new_service(form: ServiceAddForm, auth: bool = Depends(is_loggined)):
"""Add a new service"""
ipv6 = None
try:
ip_int = ip_interface(form.ip_int)
ipv6 = ip_int.version == 6
form.ip_int = str(ip_int)
form.ip_int = ip_parse(form.ip_int)
except ValueError:
return {"status":"Invalid address"}
if form.proto not in ["tcp", "udp"]:
@@ -363,7 +361,7 @@ async def add_new_service(form: ServiceAddForm, auth: bool = Depends(is_loggined
try:
srv_id = gen_service_id(db)
db.query("INSERT INTO services (service_id ,name, port, ipv6, status, proto, ip_int) VALUES (?, ?, ?, ?, ?, ?, ?)",
srv_id, refactor_name(form.name), form.port, ipv6, STATUS.STOP, form.proto, form.ip_int)
srv_id, refactor_name(form.name), form.port, True, STATUS.STOP, form.proto, form.ip_int)
except sqlite3.IntegrityError:
return {'status': 'This type of service already exists'}
await firewall.reload()

View File

@@ -1,113 +1,140 @@
from typing import Dict, List, Set
from ipaddress import ip_interface
from modules.iptables import IPTables
from utils import ip_parse, ip_family
from modules.sqlite import Service
import re, os, asyncio
import traceback
import traceback, nftables
from modules.sqlite import Regex
class FilterTypes:
INPUT = "FIREGEX-INPUT"
OUTPUT = "FIREGEX-OUTPUT"
QUEUE_BASE_NUM = 1000
class FiregexFilter():
def __init__(self, proto:str, port:int, ip_int:str, queue=None, target=None, id=None):
self.target = target
def __init__(self, proto:str, port:int, ip_int:str, queue=None, target:str=None, id=None):
self.nftables = nftables.Nftables()
self.id = int(id) if id else None
self.queue = queue
self.target = target
self.proto = proto
self.port = int(port)
self.ip_int = str(ip_int)
def __eq__(self, o: object) -> bool:
if isinstance(o, FiregexFilter):
return self.port == o.port and self.proto == o.proto and ip_interface(self.ip_int) == ip_interface(o.ip_int)
return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int)
return False
def ipv6(self):
return ip_interface(self.ip_int).version == 6
class FiregexTables:
def ipv4(self):
return ip_interface(self.ip_int).version == 4
def __init__(self):
self.table_name = "firegex"
self.nft = nftables.Nftables()
class FiregexTables(IPTables):
def raw_cmd(self, *cmds):
return self.nft.json_cmd({"nftables": list(cmds)})
def __init__(self, ipv6=False):
super().__init__(ipv6, "mangle")
self.create_chain(FilterTypes.INPUT)
self.add_chain_to_input(FilterTypes.INPUT)
self.create_chain(FilterTypes.OUTPUT)
self.add_chain_to_output(FilterTypes.OUTPUT)
def cmd(self, *cmds):
code, out, err = self.raw_cmd(*cmds)
def target_in_chain(self, chain, target):
for filter in self.list()[chain]:
if filter.target == target:
return True
return False
if code == 0: return out
else: raise Exception(err)
def add_chain_to_input(self, chain):
if not self.target_in_chain("PREROUTING", str(chain)):
self.insert_rule("PREROUTING", str(chain))
def init(self):
code, out, err = self.raw_cmd({"create":{"table":{"name":self.table_name,"family":"inet"}}})
if code == 0:
self.cmd(
{"create":{"chain":{
"family":"inet",
"table":self.table_name,
"name":"input",
"type":"filter",
"hook":"prerouting",
"prio":-150,
"policy":"accept"
}}},
{"create":{"chain":{
"family":"inet",
"table":self.table_name,
"name":"output",
"type":"filter",
"hook":"postrouting",
"prio":-150,
"policy":"accept"
}}}
)
self.reset()
def add_chain_to_output(self, chain):
if not self.target_in_chain("POSTROUTING", str(chain)):
self.insert_rule("POSTROUTING", str(chain))
def reset(self):
self.cmd({"flush":{"table":{"name":"firegex","family":"inet"}}})
def add_output(self, queue_range, proto = None, port = None, ip_int = None):
def list(self):
return self.cmd({"list": {"ruleset": None}})["nftables"]
def add_output(self, queue_range, proto, port, ip_int):
init, end = queue_range
if init > end: init, end = end, init
self.append_rule(FilterTypes.OUTPUT,"NFQUEUE",
* (["-p", str(proto)] if proto else []),
* (["-s", str(ip_int)] if ip_int else []),
* (["--sport", str(port)] if port else []),
* (["--queue-num", f"{init}"] if init == end else ["--queue-balance", f"{init}:{end}"]),
"--queue-bypass"
)
ip_int = ip_parse(ip_int)
ip_addr = str(ip_int).split("/")[0]
ip_addr_cidr = int(str(ip_int).split("/")[1])
self.cmd({ "insert":{ "rule": {
"family": "inet",
"table": self.table_name,
"chain": "output",
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(ip_int), 'field': 'saddr'}}, 'op': '==', 'right': {"prefix": {"addr": ip_addr, "len": ip_addr_cidr}}}}, #ip_int
{'match': {'left': {'meta': {'key': 'l4proto'}}, 'op': '==', 'right': str(proto)}},
{'match': {"left": { "payload": {"protocol": str(proto), "field": "sport"}}, "op": "==", "right": int(port)}},
{"queue": {"num": str(init) if init == end else f"{init}-{end}", "flags": ["bypass"]}}
]
}}})
def add_input(self, queue_range, proto = None, port = None, ip_int = None):
init, end = queue_range
if init > end: init, end = end, init
self.append_rule(FilterTypes.INPUT, "NFQUEUE",
* (["-p", str(proto)] if proto else []),
* (["-d", str(ip_int)] if ip_int else []),
* (["--dport", str(port)] if port else []),
* (["--queue-num", f"{init}"] if init == end else ["--queue-balance", f"{init}:{end}"]),
"--queue-bypass"
)
ip_int = ip_parse(ip_int)
ip_addr = str(ip_int).split("/")[0]
ip_addr_cidr = int(str(ip_int).split("/")[1])
self.cmd({"insert":{"rule":{
"family": "inet",
"table": self.table_name,
"chain": "input",
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(ip_int), 'field': 'daddr'}}, 'op': '==', 'right': {"prefix": {"addr": ip_addr, "len": ip_addr_cidr}}}}, #ip_int
{'match': {"left": { "payload": {"protocol": str(proto), "field": "dport"}}, "op": "==", "right": int(port)}},
{"queue": {"num": str(init) if init == end else f"{init}-{end}", "flags": ["bypass"]}}
]
}}})
def get(self) -> List[FiregexFilter]:
res = []
iptables_filters = self.list()
for filter_type in [FilterTypes.INPUT, FilterTypes.OUTPUT]:
for filter in iptables_filters[filter_type]:
port = filter.sport() if filter_type == FilterTypes.OUTPUT else filter.dport()
queue = filter.nfqueue()
if queue and port:
for filter in [ele["rule"] for ele in self.list() if "rule" in ele and ele["rule"]["table"] == self.table_name]:
queue_str = str(filter["expr"][2]["queue"]["num"]).split("-")
queue = None
if len(queue_str) == 1: queue = int(queue_str[0]), int(queue_str[0])
else: queue = int(queue_str[0]), int(queue_str[1])
ip_int = None
if isinstance(filter["expr"][0]["match"]["right"],str):
ip_int = str(ip_parse(filter["expr"][0]["match"]["right"]))
else:
ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}'
res.append(FiregexFilter(
target=filter_type,
id=filter.id,
target=filter["chain"],
id=int(filter["handle"]),
queue=queue,
proto=filter.prot,
port=port,
ip_int=filter.source if filter_type == FilterTypes.OUTPUT else filter.destination
proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"],
port=filter["expr"][1]["match"]["right"],
ip_int=ip_int
))
return res
async def add(self, filter:FiregexFilter):
if filter in self.get(): return None
return await FiregexInterceptor.start( iptables=self, filter=filter, n_queues=int(os.getenv("N_THREADS_NFQUEUE","1")))
def delete_all(self):
for filter_type in [FilterTypes.INPUT, FilterTypes.OUTPUT]:
self.flush_chain(filter_type)
return await FiregexInterceptor.start( filter=filter, n_queues=int(os.getenv("N_THREADS_NFQUEUE","1")))
def delete_by_srv(self, srv:Service):
for filter in self.get():
if filter.port == srv.port and filter.proto == srv.proto and ip_interface(filter.ip_int) == ip_interface(srv.ip_int):
self.delete_rule(filter.target, filter.id)
if filter.port == srv.port and filter.proto == srv.proto and ip_parse(filter.ip_int) == ip_parse(srv.ip_int):
print("DELETE CMD", {"delete":{"rule": {"handle": filter.id, "table": self.table_name, "chain": filter.target, "family": "inet"}}})
self.cmd({"delete":{"rule": {"handle": filter.id, "table": self.table_name, "chain": filter.target, "family": "inet"}}})
class RegexFilter:
@@ -159,7 +186,6 @@ class FiregexInterceptor:
def __init__(self):
self.filter:FiregexFilter
self.ipv6:bool
self.filter_map_lock:asyncio.Lock
self.filter_map: Dict[str, RegexFilter]
self.regex_filters: Set[RegexFilter]
@@ -167,21 +193,18 @@ class FiregexInterceptor:
self.process:asyncio.subprocess.Process
self.n_queues:int
self.update_task: asyncio.Task
self.iptables:FiregexTables
@classmethod
async def start(cls, iptables: FiregexTables, filter: FiregexFilter, n_queues:int = 1):
async def start(cls, filter: FiregexFilter, n_queues:int = 1):
self = cls()
self.filter = filter
self.n_queues = n_queues
self.iptables = iptables
self.ipv6 = self.filter.ipv6()
self.filter_map_lock = asyncio.Lock()
self.update_config_lock = asyncio.Lock()
input_range, output_range = await self._start_binary()
self.update_task = asyncio.create_task(self.update_blocked())
self.iptables.add_input(queue_range=input_range, proto=self.filter.proto, port=self.filter.port, ip_int=self.filter.ip_int)
self.iptables.add_output(queue_range=output_range, proto=self.filter.proto, port=self.filter.port, ip_int=self.filter.ip_int)
FiregexTables().add_input(queue_range=input_range, proto=self.filter.proto, port=self.filter.port, ip_int=self.filter.ip_int)
FiregexTables().add_output(queue_range=output_range, proto=self.filter.proto, port=self.filter.port, ip_int=self.filter.ip_int)
return self
async def _start_binary(self):
@@ -221,6 +244,7 @@ class FiregexInterceptor:
async def stop(self):
self.update_task.cancel()
if self.process and self.process.returncode is None:
self.process.kill()
async def _update_config(self, filters_codes):

View File

@@ -24,6 +24,7 @@ class FirewallManager:
del self.proxy_table[srv_id]
async def init(self):
FiregexTables().init()
await self.reload()
async def reload(self):
@@ -47,7 +48,6 @@ class ServiceManager:
def __init__(self, srv: Service, db):
self.srv = srv
self.db = db
self.firegextable = FiregexTables(self.srv.ipv6)
self.status = STATUS.STOP
self.filters: Dict[int, FiregexFilter] = {}
self.lock = asyncio.Lock()
@@ -93,13 +93,13 @@ class ServiceManager:
async def start(self):
if not self.interceptor:
self.firegextable.delete_by_srv(self.srv)
self.interceptor = await self.firegextable.add(FiregexFilter(self.srv.proto,self.srv.port, self.srv.ip_int))
FiregexTables().delete_by_srv(self.srv)
self.interceptor = await FiregexTables().add(FiregexFilter(self.srv.proto,self.srv.port, self.srv.ip_int))
await self._update_filters_from_db()
self._set_status(STATUS.ACTIVE)
async def stop(self):
self.firegextable.delete_by_srv(self.srv)
FiregexTables().delete_by_srv(self.srv)
if self.interceptor:
await self.interceptor.stop()
self.interceptor = None

View File

@@ -1,85 +0,0 @@
import os, re
from subprocess import PIPE, Popen
from typing import Dict, List, Tuple, Union
class Rule():
def __init__(self, id, target, prot, opt, source, destination, details):
self.id = id
self.target = target
self.prot = prot
self.opt = opt
self.source = source
self.destination = destination
self.details = details
def __repr__(self) -> str:
return f"Rule {self.id} : {self.target}, {self.prot}, {self.opt}, {self.source}, {self.destination}, {self.details}"
def dport(self) -> Union[int, None]:
port = re.findall(r"dpt:([0-9]+)", self.details)
return int(port[0]) if port else None
def sport(self) -> Union[int, None]:
port = re.findall(r"spt:([0-9]+)", self.details)
return int(port[0]) if port else None
def nfqueue(self) -> Union[Tuple[int,int], None]:
balanced = re.findall(r"NFQUEUE balance ([0-9]+):([0-9]+)", self.details)
numbered = re.findall(r"NFQUEUE num ([0-9]+)", self.details)
queue_num = None
if balanced: queue_num = (int(balanced[0][0]), int(balanced[0][1]))
if numbered: queue_num = (int(numbered[0]), int(numbered[0]))
return queue_num
class IPTables:
def __init__(self, ipv6=False, table="filter"):
self.ipv6 = ipv6
self.table = table
def command(self, params) -> Tuple[bytes, bytes]:
params = ["-t", self.table] + params
if os.geteuid() != 0:
exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.")
return Popen(["ip6tables"]+params if self.ipv6 else ["iptables"]+params, stdout=PIPE, stderr=PIPE).communicate()
def list(self) -> Dict[str, List[Rule]]:
stdout, strerr = self.command(["-L", "--line-number", "-n"])
lines = stdout.decode().split("\n")
res: Dict[str, List[Rule]] = {}
chain_name = ""
for line in lines:
if line.startswith("Chain"):
chain_name = line.split()[1]
res[chain_name] = []
elif line and line.split()[0].isnumeric():
parsed = re.findall(r"([^ ]*)[ ]{,10}([^ ]*)[ ]{,5}([^ ]*)[ ]{,5}([^ ]*)[ ]{,5}([^ ]*)[ ]+([^ ]*)[ ]+(.*)", line)
if len(parsed) > 0:
parsed = parsed[0]
res[chain_name].append(Rule(
id=parsed[0].strip(),
target=parsed[1].strip(),
prot=parsed[2].strip(),
opt=parsed[3].strip(),
source=parsed[4].strip(),
destination=parsed[5].strip(),
details=" ".join(parsed[6:]).strip() if len(parsed) >= 7 else ""
))
return res
def delete_rule(self, chain, id) -> None:
self.command(["-D", str(chain), str(id)])
def create_chain(self, name) -> None:
self.command(["-N", str(name)])
def flush_chain(self, name) -> None:
self.command(["-F", str(name)])
def insert_rule(self, chain, rule, *args, rulenum=1) -> None:
self.command(["-I", str(chain), str(rulenum), "-j", str(rule), *args])
def append_rule(self, chain, rule, *args) -> None:
self.command(["-A", str(chain), "-j", str(rule), *args])

View File

@@ -8,6 +8,7 @@ class SQLite():
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',
@@ -49,6 +50,17 @@ class SQLite():
return d
self.conn.row_factory = dict_factory
def backup(self):
if self.conn:
with open(self.db_name, "rb") as f:
self.__backup = f.read()
def restore(self):
if self.__backup:
with open(self.db_name, "wb") as f:
f.write(self.__backup)
self.__backup = None
def disconnect(self) -> None:
if self.conn: self.conn.close()
@@ -101,18 +113,17 @@ class SQLite():
class Service:
def __init__(self, id: str, status: str, port: int, name: str, ipv6: bool, proto: str, ip_int: str):
def __init__(self, id: str, status: str, port: int, name: str, proto: str, ip_int: str):
self.id = id
self.status = status
self.port = port
self.name = name
self.ipv6 = ipv6
self.proto = proto
self.ip_int = ip_int
@classmethod
def from_dict(cls, var: dict):
return cls(id=var["service_id"], status=var["status"], port=var["port"], name=var["name"], ipv6=var["ipv6"], proto=var["proto"], ip_int=var["ip_int"])
return cls(id=var["service_id"], status=var["status"], port=var["port"], name=var["name"], proto=var["proto"], ip_int=var["ip_int"])
class Regex:

View File

@@ -12,11 +12,11 @@ void config_updater (){
while (true){
getline(cin, line);
if (cin.eof()){
cerr << "[fatal] [upfdater] cin.eof()" << endl;
cerr << "[fatal] [updater] cin.eof()" << endl;
exit(EXIT_FAILURE);
}
if (cin.bad()){
cerr << "[fatal] [upfdater] cin.bad()" << endl;
cerr << "[fatal] [updater] cin.bad()" << endl;
exit(EXIT_FAILURE);
}
cerr << "[info] [updater] Updating configuration with line " << line << endl;

View File

@@ -4,3 +4,4 @@ uvicorn[standard]
passlib[bcrypt]
python-jose[cryptography]
fastapi-socketio
git+https://salsa.debian.org/pkg-netfilter-team/pkg-nftables#egg=nftables&subdirectory=py

View File

@@ -1,3 +1,4 @@
from ipaddress import ip_interface
import os, socket, secrets
LOCALHOST_IP = socket.gethostbyname(os.getenv("LOCALHOST_IP","127.0.0.1"))
@@ -13,3 +14,9 @@ def gen_service_id(db):
if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', res)) == 0:
break
return res
def ip_parse(ip:str):
return str(ip_interface(ip).network)
def ip_family(ip:str):
return "ip6" if ip_interface(ip).version == 6 else "ip"

View File

@@ -1,13 +1,13 @@
{
"files": {
"main.css": "/static/css/main.08225a85.css",
"main.js": "/static/js/main.0e7d88b5.js",
"main.js": "/static/js/main.70ebb0b2.js",
"index.html": "/index.html",
"main.08225a85.css.map": "/static/css/main.08225a85.css.map",
"main.0e7d88b5.js.map": "/static/js/main.0e7d88b5.js.map"
"main.70ebb0b2.js.map": "/static/js/main.70ebb0b2.js.map"
},
"entrypoints": [
"static/css/main.08225a85.css",
"static/js/main.0e7d88b5.js"
"static/js/main.70ebb0b2.js"
]
}

View File

@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"><link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"><link rel="manifest" href="/site.webmanifest"><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#FFFFFFFF"/><meta name="description" content="Firegex by Pwnzer0tt1"/><title>Firegex</title><script defer="defer" src="/static/js/main.0e7d88b5.js"></script><link href="/static/css/main.08225a85.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"><link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"><link rel="manifest" href="/site.webmanifest"><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#FFFFFFFF"/><meta name="description" content="Firegex by Pwnzer0tt1"/><title>Firegex</title><script defer="defer" src="/static/js/main.70ebb0b2.js"></script><link href="/static/css/main.08225a85.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -51,7 +51,7 @@ services:
cap_add:
- NET_ADMIN
""")
#print("Done! You can start firegex with docker-compose up -d --build")
else:
sep()
puts("--- WARNING ---", color=colors.yellow)
@@ -73,7 +73,7 @@ services:
cap_add:
- NET_ADMIN
""")
#
sep()
if not args.no_autostart:
puts("Running 'docker-compose up -d --build'\n", color=colors.green)