This commit is contained in:
Ilya Starchak
2025-12-10 02:17:54 +03:00
parent 811773e009
commit c237112077
11 changed files with 327 additions and 49 deletions

View File

@@ -68,6 +68,37 @@ All the configuration at the startup is customizable in [firegex.py](./run.py) o
- Create basic firewall rules to allow and deny specific traffic, like ufw or iptables but using firegex graphic interface (by using [nftable](https://netfilter.org/projects/nftables/))
- Port Hijacking allows you to redirect the traffic on a specific port to another port. Thanks to this you can start your own proxy, connecting to the real service using the loopback interface. Firegex will be resposable about the routing of the packets using internally [nftables](https://netfilter.org/projects/nftables/)
- EXPERIMENTAL: Netfilter Proxy uses [nfqueue](https://netfilter.org/projects/libnetfilter_queue/) to simulate a python proxy, you can write your own filter in python and use it to filter the traffic. There are built-in some data handler to parse protocols like HTTP, and before apply the filter you can test it with fgex command (you need to install firegex lib from pypi).
- Traffic Viewer allows you to monitor live network traffic for all services in real-time
- Setup Import/Export allows you to backup and restore your entire Firegex configuration as a JSON file, making it easy to deploy identical configurations across multiple servers
## Configuration Management
Firegex supports importing and exporting configurations via JSON files. This is useful for:
- Backing up your configuration
- Deploying the same setup across multiple servers
- Version controlling your firewall rules
- Quick disaster recovery
### Using the Web Interface
Navigate to "Setup Import/Export" in the sidebar to:
- **Export**: Download your current configuration as JSON
- **Import from File**: Upload a setup.json file
- **Import from JSON**: Paste JSON directly into the interface
### Using the API
```bash
# Export configuration
curl -H "Authorization: Bearer YOUR_TOKEN" \
http://localhost:4444/api/setup/export > setup.json
# Import configuration
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d @setup.json \
http://localhost:4444/api/setup/import
```
See [setup.example.json](setup.example.json) for the configuration file format.
## Documentation

View File

@@ -99,6 +99,8 @@ class FiregexInterceptor:
async def _start_binary(self):
proxy_binary_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../cpproxy"))
# Determine match mode based on protocol
match_mode = "stream" if self.srv.proto in ["tcp", "http"] else "block"
self.process = await asyncio.create_subprocess_exec(
proxy_binary_path, stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
@@ -106,7 +108,9 @@ class FiregexInterceptor:
env={
"NTHREADS": os.getenv("NTHREADS","1"),
"FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0",
"FIREGEX_NFPROXY_SOCK": self.sock_path
"FIREGEX_NFPROXY_SOCK": self.sock_path,
"MATCH_MODE": match_mode,
"PROTOCOL": self.srv.proto
},
)
nicenessify(-10, self.process.pid)

View File

@@ -6,6 +6,8 @@ def convert_protocol_to_l4(proto:str):
return "tcp"
elif proto == "http":
return "tcp"
elif proto == "udp":
return "udp"
else:
raise Exception("Invalid protocol")

View File

@@ -65,7 +65,7 @@ db = SQLite('db/nft-pyfilters.db', {
'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", "http"))',
'proto': 'VARCHAR(4) NOT NULL CHECK (proto IN ("tcp", "http", "udp"))',
'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_proto IN ("tcp", "udp"))',
'ip_int': 'VARCHAR(100) NOT NULL',
'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1',
@@ -305,7 +305,7 @@ async def add_new_service(form: ServiceAddForm):
form.ip_int = ip_parse(form.ip_int)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid address")
if form.proto not in ["tcp", "http"]:
if form.proto not in ["tcp", "http", "udp"]:
raise HTTPException(status_code=400, detail="Invalid protocol")
srv_id = None
try:

View File

@@ -1,6 +1,6 @@
# Traffic Viewer - JSON Event Format
The traffic viewer is now fully integrated. To enable structured event display, the NFProxy C++ binary (`backend/binsrc/nfproxy.cpp`) should emit JSON lines to stdout with the following format:
The traffic viewer is now fully integrated and supports **TCP, HTTP, and UDP** protocols. To enable structured event display, the NFProxy C++ binary (`backend/binsrc/nfproxy.cpp`) should emit JSON lines to stdout with the following format:
## JSON Event Schema

View File

@@ -15,6 +15,7 @@ import NFProxy from './pages/NFProxy';
import ServiceDetailsNFProxy from './pages/NFProxy/ServiceDetails';
import TrafficViewer from './pages/NFProxy/TrafficViewer';
import TrafficViewerMain from './pages/TrafficViewer';
import SetupPage from './pages/Setup';
import { useAuthStore } from './js/store';
function App() {
@@ -179,6 +180,7 @@ const PageRouting = ({ getStatus }:{ getStatus:()=>void }) => {
<Route path="traffic" element={<TrafficViewerMain />} />
<Route path="firewall" element={<Firewall />} />
<Route path="porthijack" element={<PortHijack />} />
<Route path="setup" element={<SetupPage />} />
<Route path="*" element={<HomeRedirector />} />
</Route>
</Routes>

View File

@@ -26,7 +26,7 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
validate:{
name: (value) => edit? null : value !== "" ? null : "Service name is required",
port: (value) => (value>0 && value<65536) ? null : "Invalid port",
proto: (value) => ["tcp","http"].includes(value) ? null : "Invalid protocol",
proto: (value) => ["tcp","http","udp"].includes(value) ? null : "Invalid protocol",
ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address",
}
})
@@ -115,6 +115,7 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
data={[
{ label: 'TCP', value: 'tcp' },
{ label: 'HTTP', value: 'http' },
{ label: 'UDP', value: 'udp' },
]}
{...form.getInputProps('proto')}
/>}

View File

@@ -72,7 +72,7 @@ export const HELP_NFPROXY_SIM = `➤ fgex nfproxy -h
│ * port INTEGER The port of the target to proxy [default: None] [required] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --proto [tcp|http] The protocol to proxy [default: tcp]
│ --proto [tcp|http|udp] The protocol to proxy [default: tcp] │
│ --from-address TEXT The address of the local server [default: None] │
│ --from-port INTEGER The port of the local server [default: 7474] │
│ -6 Use IPv6 for the connection │

View File

@@ -7,7 +7,7 @@ import { PiWallLight } from "react-icons/pi";
import { useNavbarStore } from "../../js/store";
import { getMainPath } from "../../js/utils";
import { BsRegex } from "react-icons/bs";
import { MdVisibility } from "react-icons/md";
import { MdVisibility, MdSettings } from "react-icons/md";
function NavBarButton({ navigate, closeNav, name, icon, color, disabled, onClick }:
{ navigate?: string, closeNav: () => void, name: string, icon: any, color: MantineColor, disabled?: boolean, onClick?: CallableFunction }) {
@@ -42,6 +42,7 @@ export default function NavBar() {
<NavBarButton navigate="porthijack" closeNav={closeNav} name="Hijack Port to Proxy" color="blue" icon={<GrDirections size={19} />} />
<NavBarButton navigate="nfproxy" closeNav={closeNav} name="Netfilter Proxy" color="lime" icon={<TbPlugConnected size={19} />} />
<NavBarButton navigate="traffic" closeNav={closeNav} name="Traffic Viewer" color="cyan" icon={<MdVisibility size={19} />} />
<NavBarButton navigate="setup" closeNav={closeNav} name="Setup Import/Export" color="teal" icon={<MdSettings size={19} />} />
{/* <Box px="xs" mt="lg">
<Title order={5}>Experimental Features 🧪</Title>
</Box>

View File

@@ -1,4 +1,4 @@
import { ActionIcon, Badge, Box, Code, Divider, Grid, LoadingOverlay, Modal, ScrollArea, Space, Table, Text, TextInput, Title, Tooltip } from '@mantine/core';
import { ActionIcon, Badge, Box, Code, Divider, Grid, Group, LoadingOverlay, Modal, ScrollArea, Select, Space, Table, Text, TextInput, Title, Tooltip } from '@mantine/core';
import { Navigate, useNavigate, useParams } from 'react-router';
import { useEffect, useState } from 'react';
import { nfproxy, nfproxyServiceQuery } from '../../components/NFProxy/utils';
@@ -30,6 +30,9 @@ export default function TrafficViewer() {
const [events, eventsHandlers] = useListState<TrafficEvent>([]);
const [loading, setLoading] = useState(true);
const [filterText, setFilterText] = useState('');
const [filterDirection, setFilterDirection] = useState<string | null>(null);
const [filterProto, setFilterProto] = useState<string | null>(null);
const [filterVerdict, setFilterVerdict] = useState<string | null>(null);
const [selectedEvent, setSelectedEvent] = useState<TrafficEvent | null>(null);
const [modalOpened, setModalOpened] = useState(false);
@@ -87,15 +90,37 @@ export default function TrafficViewer() {
};
const filteredEvents = events.filter((e: TrafficEvent) => {
if (!filterText) return true;
// Text filter
if (filterText) {
const search = filterText.toLowerCase();
return (
const matchesText = (
e.src_ip?.toLowerCase().includes(search) ||
e.dst_ip?.toLowerCase().includes(search) ||
e.verdict?.toLowerCase().includes(search) ||
e.filter?.toLowerCase().includes(search) ||
e.proto?.toLowerCase().includes(search)
e.proto?.toLowerCase().includes(search) ||
e.src_port?.toString().includes(search) ||
e.dst_port?.toString().includes(search)
);
if (!matchesText) return false;
}
// Direction filter
if (filterDirection && e.direction !== filterDirection) {
return false;
}
// Protocol filter
if (filterProto && e.proto !== filterProto) {
return false;
}
// Verdict filter
if (filterVerdict && e.verdict !== filterVerdict) {
return false;
}
return true;
});
const formatTimestamp = (ts: number) => {
@@ -147,13 +172,55 @@ export default function TrafficViewer() {
<Divider my="md" />
<Box px="md">
<Grid mb="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
placeholder="Filter by IP, verdict, filter name, or protocol..."
placeholder="Search by IP, port, verdict, filter name, or protocol..."
value={filterText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFilterText(e.currentTarget.value)}
leftSection={<FaFilter />}
mb="md"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 2 }}>
<Select
placeholder="Direction"
clearable
value={filterDirection}
onChange={setFilterDirection}
data={[
{ value: 'in', label: 'Incoming' },
{ value: 'out', label: 'Outgoing' }
]}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 2 }}>
<Select
placeholder="Protocol"
clearable
value={filterProto}
onChange={setFilterProto}
data={[
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
{ value: 'http', label: 'HTTP' }
]}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 2 }}>
<Select
placeholder="Verdict"
clearable
value={filterVerdict}
onChange={setFilterVerdict}
data={[
{ value: 'accept', label: 'Accept' },
{ value: 'drop', label: 'Drop' },
{ value: 'reject', label: 'Reject' },
{ value: 'edited', label: 'Edited' }
]}
/>
</Grid.Col>
</Grid>
<ScrollArea style={{ height: 'calc(100vh - 280px)' }}>
<Table striped highlightOnHover>

View File

@@ -1,20 +1,107 @@
import { ActionIcon, Badge, Box, Card, Divider, Group, LoadingOverlay, Space, Text, ThemeIcon, Title, Tooltip } from '@mantine/core';
import { ActionIcon, Badge, Box, Card, Divider, Group, LoadingOverlay, Select, Space, Text, TextInput, ThemeIcon, Title, Tooltip } from '@mantine/core';
import { useNavigate } from 'react-router';
import { nfproxyServiceQuery } from '../../components/NFProxy/utils';
import { nfregexServiceQuery } from '../../components/NFRegex/utils';
import { isMediumScreen } from '../../js/utils';
import { MdDoubleArrow, MdVisibility } from 'react-icons/md';
import { TbPlugConnected } from 'react-icons/tb';
import { FaServer } from 'react-icons/fa';
import { FaFilter, FaServer } from 'react-icons/fa';
import { BsRegex } from 'react-icons/bs';
import { useState } from 'react';
type UnifiedService = {
service_id: string;
name: string;
status: string;
port: number;
proto: string;
ip_int: string;
type: 'nfproxy' | 'nfregex';
stats: {
edited_packets?: number;
blocked_packets?: number;
n_packets?: number;
};
};
export default function TrafficViewer() {
const services = nfproxyServiceQuery();
const nfproxyServices = nfproxyServiceQuery();
const nfregexServices = nfregexServiceQuery();
const navigate = useNavigate();
const isMedium = isMediumScreen();
const [filterText, setFilterText] = useState('');
const [filterType, setFilterType] = useState<string | null>(null);
const [filterProto, setFilterProto] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<string | null>(null);
if (services.isLoading) return <LoadingOverlay visible={true} />;
if (nfproxyServices.isLoading || nfregexServices.isLoading) {
return <LoadingOverlay visible={true} />;
}
const activeServices = services.data?.filter(s => s.status === 'active') || [];
const stoppedServices = services.data?.filter(s => s.status !== 'active') || [];
// Combine services from both modules
const allServices: UnifiedService[] = [
...(nfproxyServices.data?.map(s => ({
service_id: s.service_id,
name: s.name,
status: s.status,
port: s.port,
proto: s.proto,
ip_int: s.ip_int,
type: 'nfproxy' as const,
stats: {
edited_packets: s.edited_packets,
blocked_packets: s.blocked_packets
}
})) || []),
...(nfregexServices.data?.map(s => ({
service_id: s.service_id,
name: s.name,
status: s.status,
port: s.port,
proto: s.proto,
ip_int: s.ip_int,
type: 'nfregex' as const,
stats: {
n_packets: s.n_packets
}
})) || [])
];
// Apply filters
const filteredServices = allServices.filter(service => {
// Text filter
if (filterText) {
const search = filterText.toLowerCase();
const matchesText = (
service.name.toLowerCase().includes(search) ||
service.service_id.toLowerCase().includes(search) ||
service.port.toString().includes(search) ||
service.ip_int.toLowerCase().includes(search)
);
if (!matchesText) return false;
}
// Type filter
if (filterType && service.type !== filterType) {
return false;
}
// Protocol filter
if (filterProto && service.proto !== filterProto) {
return false;
}
// Status filter
if (filterStatus) {
if (filterStatus === 'active' && service.status !== 'active') return false;
if (filterStatus === 'stopped' && service.status === 'active') return false;
}
return true;
});
const activeServices = filteredServices.filter(s => s.status === 'active');
const stoppedServices = filteredServices.filter(s => s.status !== 'active');
return <>
<Box px="md" mt="lg">
@@ -26,20 +113,72 @@ export default function TrafficViewer() {
Traffic Viewer
</Title>
<Text c="dimmed" mt="sm">
Monitor live network traffic for all NFProxy services
Monitor live network traffic for NFProxy and NFRegex services
</Text>
</Box>
<Divider my="lg" />
{services.data?.length === 0 ? (
<Box px="md" mb="lg">
<Group grow>
<TextInput
placeholder="Search by name, ID, port, or IP..."
value={filterText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFilterText(e.currentTarget.value)}
leftSection={<FaFilter />}
/>
<Select
placeholder="Service Type"
clearable
value={filterType}
onChange={setFilterType}
data={[
{ value: 'nfproxy', label: 'Netfilter Proxy' },
{ value: 'nfregex', label: 'Netfilter Regex' }
]}
/>
<Select
placeholder="Protocol"
clearable
value={filterProto}
onChange={setFilterProto}
data={[
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
{ value: 'http', label: 'HTTP' }
]}
/>
<Select
placeholder="Status"
clearable
value={filterStatus}
onChange={setFilterStatus}
data={[
{ value: 'active', label: 'Active' },
{ value: 'stopped', label: 'Stopped' }
]}
/>
</Group>
</Box>
{allServices.length === 0 ? (
<Box px="md">
<Title order={3} className='center-flex' style={{ textAlign: "center" }}>
No NFProxy services found
No services found
</Title>
<Space h="xs" />
<Text className='center-flex' style={{ textAlign: "center" }} c="dimmed">
Create a service in the Netfilter Proxy section to start monitoring traffic
Create services in Netfilter Proxy or Netfilter Regex to start monitoring traffic
</Text>
</Box>
) : filteredServices.length === 0 ? (
<Box px="md">
<Title order={3} className='center-flex' style={{ textAlign: "center" }}>
No services match your filters
</Title>
<Space h="xs" />
<Text className='center-flex' style={{ textAlign: "center" }} c="dimmed">
Try adjusting your filter criteria
</Text>
</Box>
) : (
@@ -51,15 +190,32 @@ export default function TrafficViewer() {
Running Services
</Title>
{activeServices.map(service => (
<Card key={service.service_id} shadow="sm" padding="lg" radius="md" withBorder mb="md">
<Card key={`${service.type}-${service.service_id}`} shadow="sm" padding="lg" radius="md" withBorder mb="md">
<Group justify="space-between">
<Box>
<Group>
<ThemeIcon color="lime" variant="light" size="lg">
<ThemeIcon
color={service.type === 'nfproxy' ? 'lime' : 'grape'}
variant="light"
size="lg"
>
{service.type === 'nfproxy' ? (
<TbPlugConnected size={20} />
) : (
<BsRegex size={20} />
)}
</ThemeIcon>
<div>
<Group gap="xs">
<Text fw={700} size="lg">{service.name}</Text>
<Badge
size="xs"
color={service.type === 'nfproxy' ? 'lime' : 'grape'}
variant="dot"
>
{service.type === 'nfproxy' ? 'Proxy' : 'Regex'}
</Badge>
</Group>
<Group gap="xs" mt={4}>
<Badge color="cyan" size="sm">:{service.port}</Badge>
<Badge color="violet" size="sm">{service.proto}</Badge>
@@ -71,13 +227,21 @@ export default function TrafficViewer() {
<Box>
<Group>
<Box style={{ textAlign: 'right' }}>
{service.type === 'nfproxy' ? (
<>
<Badge color="orange" size="sm" mb={4}>
{service.edited_packets} edited
{service.stats.edited_packets || 0} edited
</Badge>
<br />
<Badge color="yellow" size="sm">
{service.blocked_packets} blocked
{service.stats.blocked_packets || 0} blocked
</Badge>
</>
) : (
<Badge color="yellow" size="sm">
{service.stats.n_packets || 0} blocked
</Badge>
)}
</Box>
<Tooltip label="View traffic">
<ActionIcon
@@ -85,11 +249,12 @@ export default function TrafficViewer() {
size="xl"
radius="md"
variant="filled"
onClick={() => navigate(`/nfproxy/${service.service_id}/traffic`)}
onClick={() => navigate(`/${service.type}/${service.service_id}/traffic`)}
>
<MdDoubleArrow size="24px" />
</ActionIcon>
</Tooltip>
</Tooltip>
</Group>
</Box>
</Group>
@@ -110,11 +275,16 @@ export default function TrafficViewer() {
<Group justify="space-between">
<Box>
<Group>
<ThemeIcon color="gray" variant="light" size="lg">
<FaServer size={18} />
<ThemeIcon color={service.type === 'nfproxy' ? 'lime' : 'grape'} variant="light" size="lg">
{service.type === 'nfproxy' ? <TbPlugConnected size={18} /> : <BsRegex size={18} />}
</ThemeIcon>
<div>
<Group gap="xs">
<Text fw={500} size="lg" c="dimmed">{service.name}</Text>
<Badge color="gray" size="sm">
{service.type === 'nfproxy' ? 'Proxy' : 'Regex'}
</Badge>
</Group>
<Group gap="xs" mt={4}>
<Badge color="gray" size="sm">:{service.port}</Badge>
<Badge color="gray" size="sm">{service.proto}</Badge>