Compare commits

...

21 Commits

Author SHA1 Message Date
449b1a5ac6 feat: update BYO pipeline model support 2026-05-24 23:56:04 +08:00
84a1c29e1e Refactor backend architecture and code organization (#20) 2026-05-06 00:52:50 +08:00
67dcae2e40 Add multi-source detection consensus, custom model uploads, and live log streaming (#19) 2026-04-19 20:01:32 +08:00
fbaef94082 feat: architecture, detection, security, SOAR, operations (#18)
* feat: Phase 2-5 — architecture, detection, security, SOAR, operations

Architecture:
- Hexagonal port traits (10 modules migrated from Arc<Database>)
- Domain model types moved to model/ directory
- Constants centralized + 7 made runtime-configurable via DB
- Dead Error/Log variants cleaned up, SystemLog split

Detection (Phase 5):
- Detection orchestrator with dedup + enrichment + source attribution
- Cross-flow correlation engine: botnet, scan, lateral movement (T9)
- Temporal beaconing detector: CV-based C2 periodicity (T10)
- LRU flow eviction replacing O(n) min_by_key scan (T12)

Security hardening:
- 7 fixes: alg:none, config secret leak, HTTPS open redirect,
  log traversal, HKDF salt, SOAR whitelist+cooldown, operator validation
- 4 memory safety fixes: LRU dedup, frequency cleanup, drift cap, clock
- Envelope encryption for secrets (AES-256-GCM + HKDF)
- 17 new tests (SecretStore + SOAR conditions)

SOAR (Phase 3):
- Multi-condition playbooks (5 condition types, AND logic)
- Playbook update API (PUT + toggle endpoints)

Operations (Phase 4):
- Dynamic log level, system control APIs (shutdown/restart)
- HTTP config hot reload, spawn_blocking for CPU-bound work
- CLI encrypt-db / decrypt-db commands
- Audit log API

Log level audit:
- 16 variants adjusted (noisy hot-path → TRACE/DEBUG)
- 5 dead variants removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Copilot review — 6 issues from PR #18

1. Botnet detector source_ip was set to victim dst_ip, causing SOAR
   to block the victim instead of the attacker
2. HTTPS redirect host header injection: validate host is private IP,
   localhost, or .local hostname before constructing redirect URL
3. smtp_password plaintext residue: clear settings table after writing
   to SecretStore to prevent pre-migration plaintext from persisting
4. install.sh: add apt-get update before install on Debian/Ubuntu
5. download_log OOM risk: add 50MB file size limit before reading
6. update_config restart trigger: check return value, report if
   shutdown already in progress instead of claiming success

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Agent Team review — security, perf, correctness

Security:
- S1: Add RBAC permission check for /api/logs/ and /api/audit/ endpoints
  (previously any authenticated user could access)
- S2/S3: Remove report_dir and log_dir from configurable settings to
  prevent arbitrary directory write via config API
- A2: Pin DNS-resolved IPs in webhook reqwest client to prevent DNS
  rebinding TOCTOU attack (resolve() instead of re-resolving)

Performance:
- P7: Add 50K key cap to FrequencyTracker to prevent unbounded growth
  under DDoS (was unbounded, worst case 1.6GB)
- P9: Increase ML alert broadcast capacity 100 → 1024 to prevent lost
  alerts during DDoS spikes (3 subscribers contend on 100-slot buffer)
- P2: Reduce FLOW_MAX_PERIODS 10000 → 1000 (saves 144KB/flow, feature
  extraction only uses aggregate stats)
- P1: Remove unnecessary FlowKey clone on hot path (~1.9MB/s saved)
- P5: Beaconing detector: split analyze_and_alert into read-lock scan
  + selective write-lock update (reduces DashMap contention)

Correctness:
- A4: Capture correlation counts inside DashMap guard before dropping,
  eliminating TOCTOU in logged values (botnet, scan, lateral)
- A6: Log warning when SOAR playbook action params JSON is malformed
  instead of silently replacing with empty object

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add trainer submodule, update frontend submodule

- Add net-guardia-trainer submodule (ParrotXray/NetGuardia-Trainer@dalaw2-dev)
- Update frontend submodule with code quality fixes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:56:33 +08:00
12e0ff70cc feat: GeoIP replacement, Vue 3 frontend, setup wizard, DDD architecture, UI/UX fixes, i18n, security hardening (#17) 2026-03-28 11:30:35 +08:00
DaLaw2
d47d08d79e feat: RBAC, clippy fixes, frontend CI, integration test (#16)
* feat: SQLite persistence for ACL, rate limit, DNS, and GeoIP rules

Add rusqlite with WAL mode for persisting all security rules. On
startup, load persisted state into eBPF maps. On API writes, persist
to DB alongside eBPF updates (DB-first for crash safety).

Tables: users, acl_rules, rate_limit_config, dns_blacklist,
geo_blocked_countries, settings. Database module uses parking_lot
Mutex for thread-safe access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: JWT authentication with RBAC and default admin

Auth: JWT token-based authentication with argon2 password hashing.
Auto-generated secret persisted in SQLite settings table. Middleware
validates Bearer tokens on all /api/* endpoints except /api/auth/login.

RBAC: admin (all operations) and viewer (GET only). Default admin
user created on first run (password: "admin", logged as warning).

Endpoints: POST /api/auth/login, POST /api/auth/register (admin only),
GET /api/auth/me. WebSocket endpoints validate ?token= query parameter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Ed25519 license validation and generator

Validator (net-guardia): Reads license.key (base64 payload + signature),
verifies Ed25519 signature against embedded public key, checks expiry.
Optional — missing license logs warning, invalid/expired fails startup.
GET /api/system/license exposes license info.

Generator (license-generator): Standalone crate, not in workspace.
Subcommands: keygen (Ed25519 keypair), issue (sign license with
device_id/expires/features), verify (check license file).

Public key placeholder (all zeros) — replace after running keygen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: security hardening, monitor mode, SMTP reports, XDP fallback, tests & deploy tooling

- Security: force password change on first login, auth input validation
  (password ≥8 chars, username alphanumeric), login rate limiting (5 failures
  → 15min lockout), change-password API endpoint
- DB: From<rusqlite::Error> trait impl eliminates ~20 duplicated map_err calls
- XDP: fallback chain DRV_MODE → SKB_MODE → clear error with supported NIC list
- Monitor mode: enforce_mode setting (monitor/enforce) with GET/PUT API
- CORS: switched from hardcoded localhost to permissive for appliance deployment
- Email: SMTP weekly report module (lettre) with HTML template and cron scheduler
- Health: disk usage monitoring with >90% warning and >95% critical alerts
- System API: XDP mode reporting, enforce mode toggle endpoints
- Tests: 19 unit tests covering DB CRUD, JWT lifecycle, password hashing,
  license date calculations, and login lockout
- Deploy: setup wizard (bash/whiptail), systemd service with watchdog,
  logrotate config, Packer VM template (OVA + QCOW2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: hexagonal architecture with ports, adapters, and CQRS event bus

- Architecture: reorganize into hexagonal layers (interface/, adapter/,
  infrastructure/) with strict unidirectional dependency rules
- interface/communication: CQRS message bus (Command, Query, Event traits)
  adapted from MirrorSphere's CommunicationManager pattern
- interface/port: define 5 port traits (RepositoryPort, AuthPort, HealthPort,
  NotificationPort, PacketProcessorPort) for dependency inversion
- infrastructure: extract ServiceFactory and HttpServer from God Object
  (system.rs reduced from 503 to ~120 lines), add CommunicationManager
- adapter/http: move web/api/ handlers, use dyn RepositoryPort trait objects
  instead of concrete Database type
- adapter/websocket: move web/websocket/ handlers + route definitions
- adapter/persistence: move core/database/, implement RepositoryPort trait
- Fix layer violations: model/ no longer imports core/, adapters don't
  cross-import each other
- Define 10 command types, 8 query types, 6 event types for subsystem
  communication
- Add 10 new tests (29 total): CommunicationManager dispatch (9 tests),
  RepositoryPort trait object verification (1 test)

Dependency rules enforced:
  model/ → (no imports from other layers)
  interface/ → model/ only
  adapter/ → interface/ + model/ (no cross-adapter imports)
  infrastructure/ → all layers (composition root)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: rename MLService to AppServices and move to infrastructure/

MLService was misleadingly named — it held SystemHealth and FlowStatistics
alongside ML components. Renamed to AppServices and moved from
core/infrastructure/ to infrastructure/ where service orchestration belongs
in the hexagonal architecture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: move core/infrastructure/ to infrastructure/, fix layer violations

- Move app_config, health, statistics, geoip from core/infrastructure/ to
  infrastructure/ — completes hexagonal layer separation
- Rename MLService to AppServices (name reflected actual contents: health,
  statistics, ML engine, not just ML)
- Fix core/ → adapter/ dependency violations: jwt.rs, email/scheduler.rs,
  email/report.rs now use dyn RepositoryPort trait instead of concrete Database
- Move misplaced data types to model/:
  - Claims → model/auth.rs
  - AlertMessage → model/ml_detection.rs
  - LicensePayload + LicenseInfo → model/license.rs
  - DropEventMessage + DropCounters → model/drop_event.rs
  - InferenceConfig (ML JSON) → model/config.rs as MLInferenceConfig
- Wire CommunicationManager: enforce mode flow now goes through CQRS
  (ChangeEnforceModeCommand + GetEnforceModeQuery via EnforceModeHandler)
- Add GitHub Actions CI workflow (cargo check + test + clippy)
- Add 9 new tests (38 total): enforce mode handler (3), auth validation (6)
- core/ now contains only business logic with no adapter imports
  (except #[cfg(test)] blocks which need concrete types)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update frontend submodule to feat/ml-page branch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: frontend overhaul, design system, CI fix

Frontend (submodule update):
- JWT authentication with login page and route protection
- WebSocket refactor: 26 connections → 4 with subscription filtering
- All API paths migrated from /ebpf/ to /api/ with JWT headers
- 6 new pages: drops, geo-block, dns-filter, rate-limit,
  protocol-filter, system settings
- 3-group sidebar navigation (監控/安全/系統)
- Updated all existing pages to new backend API

Design system:
- DESIGN.md: Industrial/Utilitarian aesthetic, Geist + JetBrains Mono,
  Slate palette, compact spacing, accessibility specs
- CLAUDE.md: design system reference for future work
- TODOS.md: implementation tracking

CI fix:
- Add Node.js 22 setup + npm install for frontend build in build.rs
- Fix bpf-linker resolution: find_bpf_linker() in net-guardia/build.rs
  resolves path and passes via CARGO_TARGET_BPFEL_UNKNOWN_NONE_LINKER
  env var to eBPF subprocess (no more PATH guessing)
- Add which crate to net-guardia build-dependencies
- Force-install bpf-linker to avoid stale cache false positive
- Set stable as default toolchain so clippy runs on stable
- Make ingress/egress-ebpf build.rs non-fatal on which() failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: clean image names, gitignore project docs, frontend design review

- deploy: add explicit image names (netguardia, netguardia-router, netguardia-endpoint)
- gitignore: exclude CLAUDE.md, DESIGN.md, TODOS.md from tracking
- frontend: Toast system, skeleton loading, mobile sidebar, a11y, cross-nav links

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update frontend submodule — UX fixes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: RBAC user groups, permission middleware, account management APIs

Backend:
- User groups with junction table (user_group_members)
- Permission-based middleware replacing role-based (viewer=GET only)
- Permissions resolved as union of all user's group permissions
- Default groups: Administrator (all perms) + Viewer (read-only)
- Auto-migration: seed groups + assign existing users on first run
- User management APIs: list, delete, reset-password
- Group management APIs: CRUD + member assignment
- Protected: admin account (no delete/group change), built-in groups (no edit/delete)
- JWT claims include permissions array from groups

Deploy:
- setup.sh uses absolute path for compose file
- config.toml: combined_queue_count=1 for veth interfaces

Frontend submodule updated to include RBAC UI + i18n.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve clippy errors, add frontend CI steps and integration test

- Fix 10 clippy errors: type_complexity (add UserListItem/UserGroupTuple
  type aliases) and collapsible_if (collapse nested if-let chains)
- Add frontend type check (tsc --noEmit) and build (next build) to CI
- Move integration test script into repo at tests/integration_test.sh
  with hardcoded password removed (uses direct sudo instead)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: gitignore VERSION, CHANGELOG, and SQLite db files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update frontend submodule — add CI pipeline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove frontend CI from main repo (frontend has its own CI)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update frontend submodule

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:50:53 +08:00
DaLaw2
ebc7edcead feat: symmetric hash, drop events, GeoIP/DNS, performance & bug fixes (#15)
* fix: ML formula alignment, dependency cleanup, and code quality

- Restore correct MSE denominator (ae_feature_names.len())
- Align feature extraction with CICFlowMeter: payload bytes, sample std (N-1),
  min_seg_size_forward without payload filter, act_data_pkt_fwd skip first packet,
  init_win_bytes_bwd stores last packet
- Organize workspace dependencies and update all crates to latest
- Fix libxdp-sys 0.2.4 clang 20 build (enable use_cc_build + use_precompiled_bpf)
- Pin aya-ebpf =0.1.1 (0.1.2 yanked, aya-rs/aya#1400)
- Remove all comments and dead code
- Use macro-generated constructors for error types
- Replace eprintln/tracing::error with log! macro
- Clean up duplicate/unused error and log variants, fix log levels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct UDP/TCP/mid-stream forward/backward direction logic

* fix: symmetric hash redirect, per-queue optimization, and ICMP ACL support

eBPF: Add XOR symmetric hash to transmission stage so both directions of
a flow land on the same XSK queue, fixing the RSS asymmetry bug that
caused FlowTracker to only see one direction. Egress uses stack-local
ParsedPacket to avoid unnecessary map allocation. Allow non-TCP/UDP
packets through the pipeline so ACL can block ICMP.

Userspace: Revert shared tracker to per-queue trackers now that symmetric
hash guarantees bidirectional visibility. Replace Arc<Mutex<Vec<FrameDesc>>>
with private Vec per XskPair to eliminate hot-path lock contention. Add
pre-allocated BufferPool and parse-before-clone to reduce per-packet malloc.
Log partial sends when frames < packets.

Config: Add mtu, packet_buffer_size, buffer_pool_capacity to [Network]
with serde defaults for backward compatibility.

Cleanup: Remove unused Direction::flip(), FlowTracker::drain_flows(),
FlowStatistics broadcast/subscribe, MAX_BUFFERED_PACKETS, and
Engine::process_packet wrapper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: drop event ring buffer, double-buffer flow tracker, and review fixes

eBPF: Add 256KB RingBuf map for drop event reporting. Each XDP_DROP site
(ACL blacklist, rate limit, protocol filter) emits a DropEvent with
reason code. Refactor rate_limit::should_drop to return Option<u8> with
specific reason. Extract rate limit defaults to common/define/setting.

Userspace: Add DropMonitor with tokio async consumer, broadcast channel,
DropCounters, and /api/stats/drops REST + /ws/drops WebSocket endpoints.

FlowTracker: Replace O(flows) lock with O(1) take_snapshot() swap.
Inference tick now does phase-1 swap under lock, phase-2 filter outside
lock. Removes cleanup_old_flows, get_flows_for_inference,
drain_flows_for_logging.

Review fixes: explicit IPv6 match in symmetric_hash, consistent naming
(compute_symmetric_queue_id), reason_to_str helper, std::net::Ipv6Addr
formatting, WebSocketLagged typo, remove unused mtu config field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move IP field writes before L4 match to prevent eBPF dead store elimination

The eBPF LLVM backend was optimizing away the non-TCP/UDP path by
treating ParsedPacket writes as dead stores (reads happen in separate
tail-called programs). Moving IP-level fields (timestamp, src/dst IP,
packet_length, ip_version, protocol) before the L4 protocol match
ensures they are written regardless of protocol, sharing code path
with TCP/UDP and preventing branch merging with the error path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: GeoIP LPM Trie country blocking and DNS query blacklist

eBPF: Add LPM Trie maps (GEO_BLOCK_V4/V6) in access_control for
country-level IP blocking with big-endian keys for correct prefix
matching. Add DNS query name parser in dns_filter with bounded loops,
lowercase normalization, and subdomain matching via parent domain
iteration. Both emit drop events with new reason codes.

Userspace: Add GeoBlock manager that loads CIDR prefixes from MaxMind
GeoLite2 database into LPM Tries per blocked country. Add DnsFilter
manager with wire-format domain conversion. New REST endpoints:
PUT/DELETE/GET /api/acl/geo/{block,unblock,blocked} and
PUT/DELETE/GET /api/filter/dns/blacklist.

Review fixes: DNS header bounds check off-by-one (+2 to +3),
total_prefixes accumulation (= to +=), proper InvalidDnsName error
type replacing InvalidMapType abuse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review fixes for GeoIP and DNS features

GeoIP: Fix LPM Trie endianness (from_be_bytes → from_ne_bytes for
consistent memory layout). Move whitelist check before geo/blacklist
(whitelist should always take priority). Batch block/unblock API to
rebuild tries only once. Minimize rebuild gap by collecting entries
before locking. Use config path for GeoIP DB. Validate country codes
(2-letter alpha).

DNS: Fix bounds check off-by-one (dns_header+2 → +3). Add QDCOUNT>0
check. Replace InvalidMapType error with proper InvalidDnsName variant.
Add domain count limit per request (1000). Wrap API responses in JSON
objects for consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add deployment and testing infrastructure

Add container definitions, compose config, and traffic generator
scripts for realistic inline deployment testing.

deploy/compose/ — Containerfiles for netguardia, router, endpoints
                  and podman-compose.yml with management network
deploy/scripts/ — setup.sh (veth/namespace wiring),
                  traffic-external.sh and traffic-internal.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: P0 bugs — IPv4 IHL parsing, TX frame leak, SSH whitelist logic

IPv4 IHL: L4 header offset was hardcoded to 34 (20-byte IPv4 header).
Now reads IHL field dynamically (20-60 bytes) so IP options don't
cause wrong port/flag parsing. Attackers could previously bypass
port-based rules by adding IP options.

TX frame leak: tx.produce() may submit fewer frames than provided.
Unsubmitted FrameDescs were lost, permanently shrinking frame_pool.
Now returns unsubmitted frames to pool.

SSH whitelist: Array<PlaceHolder>.get(0).is_some() always returns true
(zero-initialized entries exist). Changed to check actual value != 0,
matching userspace enable(1)/disable(0) semantics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: P1 bugs — rate_limit real values, ML status, DNS rate protocol check

rate_limit API: GET /api/rate-limit/config now reads actual values from
eBPF Array map instead of returning hardcoded defaults. Added getter
methods for each rate limit parameter.

ML status API: GET /api/ml/status now returns real engine state (mode,
tracker count, flow count, inference interval) instead of hardcoded
{"active": true}.

DNS rate limit: Added UDP protocol check before dst_port==53 test.
TCP connections to port 53 no longer incorrectly trigger DNS rate
limiter alongside packet rate limiter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: P2 fixes — binary IPs, pre-alloc buffers, build.rs dedup

parse_packet: Replace String IP addresses with [u8; 16] binary in
UserPacket, eliminating 2M heap allocations/sec at 1Mpps. FlowKey
copies bytes directly instead of string-parse-to-bytes round-trip.

xsk_manager: Pre-allocate comp_descs (256) and rx_descs (64) once
before the main loop instead of per-iteration vec![] allocation.

build.rs: Extract duplicate build_ingress_ebpf/build_egress_ebpf into
shared build_ebpf_package(). Add cargo:rerun-if-changed for common/src
to fix stale eBPF build cache when common crate changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: P2 fixes — feature_extractor precompute, filter.rs dedup

feature_extractor: Pre-compute all statistics once via PrecomputedStats
struct instead of recomputing per feature name. ~70x speedup for the
72-feature extraction step. Public API unchanged.

filter.rs: Extract ok_or_error() helper to eliminate 16 instances of
duplicated match-result-to-HttpResponse pattern. File reduced from
338 to 243 lines with identical behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: P3 code quality — release profile, dep cleanup, dead code, safety

Release profile: Enable opt-level=3, lto="thin", strip=true.

Dependencies: Remove unused `futures` crate. Change tokio from "full"
to selective features (rt-multi-thread, macros, sync, time). Remove
commented-out csv/anyhow deps.

Dead code: Remove unused get_attack_label(), unnecessary
#[allow(dead_code)] annotations where code is actually used.

Safety: Add bounds check in PortRule::to_port_vec to prevent OOB if
count is corrupted. Fix set_config to propagate errors instead of
silently ignoring with `let _ =`.

Misc: Add .env, .DS_Store, profiling files to .gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: ParrotXray <b05817691@gmail.com>
2026-03-21 15:41:36 +08:00
DaLaw2
18a2d4a168 refactor: Complete architecture overhaul and code review fixes (#14)
* refactor: Complete architecture overhaul and code review fixes

eBPF:
- Replace Event enum with flat ParsedPacket struct (56 bytes)
- Replace TcpFlags (8 bools) with u8 bitmap constants
- Remove all eBPF statistics (24 maps) — moved to userspace
- Replace port magic number with PortRule struct (match_all flag)
- Add rate_limit stage: packet/SYN/UDP/DNS per-IP rate limiting
- Implement dynamic pipeline via NEXT_STAGE map
- Flatten egress to single XSK redirect
- Fix TCP data offset validation and header bounds checks
- Fix HTTP protocol_filter: None→false, use pkt.tcp_flags
- Fix rate limit window off-by-one
- Fix verifier bounds check for packet access
- Add static assertions for header sizes

Userspace:
- Move ml/ → core/ml/, rename AppServices → MLService
- Rename service → protocol_filter
- Per-thread FlowTracker with parking_lot::Mutex
- Extract EngineConfig, remove PacketProcessor wrapper
- Add UserPacket, FlowStatistics, RateLimitConfig
- Split config.toml into sections
- FlowKey bytes, FlowData memory limits, EntryMap generics
- Fix MSE denominator, segment sizes, flow eviction
- Fix CORS, config validation, FD race, packet bounds
- Fix IPv6 RFC 5952, add ICMP support, ML config validation
- Add safety comments, DOS protection, configurable log level

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: Restructure REST API with /api/v1/ prefix

- Add /api/v1/ prefix for all REST endpoints
- Move WebSocket routes to /ws/ (health, alerts, flows)
- Rename: access_control → acl, service → filter, misc → system
- Restructure filter into /filter/http and /filter/ssh sub-scopes
- Add rate-limit config API (GET/PUT /api/v1/rate-limit/config)
- Wire flow_stats_ws to /ws/flows
- Fix all error responses to JSON format
- Fix double JSON serialization (.json(web::Json(x)) → .json(x))
- Remove old control/ directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: WebSocket flow stats with subscription-based filtering

- Add direction and last_seen_us fields to FlowStatsEntry
- Add FlowSubscription type for client-side query filters
- WebSocket /ws/flows now supports subscription messages:
  {"direction": "ingress", "window_secs": 60, "top_n": 10, "interval_secs": 3}
- Client can update filter at any time by sending new subscription JSON
- Server responds immediately with filtered data on subscription change
- Default: all flows, no filter, 5 second push interval
- Remove broadcast channel from FlowStatistics (per-client filtering instead)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:00:03 +08:00
26f2e1851b Feat/ml api (#13)
* wip: implement ml inference api

* wip: adjust code

* feat: Add ML alert api components

* add: Add private ip analysis

* fix: Fix compile error

* refactor: Change compilation place of frontend

* refactor: Change compilation place of frontend

* feat: Using cic2018 models and fix feature extraction

* feat: Using the torch models

* wip: Continuous optimization model

* wip: Add TODO

* wip: Add csv record

* wip: Continuously improve the inference

* fix: use actual bulk duration for Fwd/Bwd Avg Bulk Rate

* chore: adjust code
2026-03-18 15:25:28 +08:00
55edf01113 feat: Complete basic ml inference (#12)
* wip: use tract-onnx

* feat: Implement ML models loading

* wip: adjust code

* add: Add NetGuardia-FrontEnd as submodule

* wip: make ml inference

* wip: make ml inference

* feat: Implement ml inference

* feat: improvement ml inference

* feat: Complete ml inference

* refactor: Change the log! and usize method
2026-01-28 16:20:24 +08:00
f2c4be77a6 feat: Add machine learning components (#11)
* feat: cat cat

* fix: resolve frame pool exhaustion and packet forwarding issues

* refactor: implement non-blocking ML detection with dedicated threads

* add: Add GeoIP

* fix: Fix IP address sequence reversal issue

* fix: Fix access control IP address sequence reversal issue

* add: Add onnx models

* wip

* wip: refactor

* wip: Re-form ml

---------

Co-authored-by: DaLaw2 <t20040421@gmail.com>
2026-01-19 18:07:49 +08:00
DaLaw2
32d93906f2 doc: Update README.md and license (#10) 2025-11-01 22:31:01 +08:00
DaLaw2
d93b43f463 fix: Fix AF_XDP not working (#9) 2025-10-15 15:51:37 +08:00
DaLaw2
97d511d17a wip: Add AF_XDP support (#8)
* wip: remove singleton pattern

* wip: Fix transmission func not working

* wip: Fix transmission func not working

* fix: Fix ebpf run on bpflib 0.5 error

* fix: Build frontend error and websocket not work

* misc: Use AF_XDP replace RingBuffer

* feat: Complete XskManager and Xsk

* fix: Ingress Ebpf Attach Failed
2025-10-13 13:16:44 +08:00
DaLaw2
24af672c41 feat: Adjust project structure (#7) 2025-09-13 14:57:20 +08:00
ParrotXray
5987f2793d refactor: Add WebSocket support for alert and health metrics, and refactor flow handling (#6) 2025-08-31 21:05:29 +08:00
DaLaw2
c4598970a2 refactor: Refactor packet parsing and event handling for better speed (#5) 2025-08-31 13:18:26 +08:00
DaLaw2
688314da66 feat: Remove frontend files, replace with auto build and copy into project dir (#4) 2025-08-30 19:22:20 +08:00
DaLaw2
3c912bb2d4 feat: Add transmission impl (#3)
* refactor: Adjust project structure, update dependencies, etc.

* feat: Add transmission impl
2025-08-30 15:31:13 +08:00
DaLaw2
d01045e74a refactor: Remove Ebpf* type, replace with normal struct with Pod trait (#2) 2025-08-30 03:38:12 +08:00
DaLaw2
f021c8ebe2 chore: Merge from ParrotXray master (#1)
* refactor: Use unsafe blocks for flow statistics updates in ingress and egress EBPF

fix: Ensure proper error handling in parsing and access control functions

* Add detection and statistics pages with navigation updates

- Created detection.html and statistics.html with a consistent layout.
- Implemented a sidebar navigation menu with links to Home, Dashboard, Statistics, Access Control, and Detection.
- Added loading spinner to enhance user experience during data fetching.
- Included favicon.ico for branding purposes.

* refactor: Remove commented-out code for load average and CPU temperature in SystemHealth

* refactor: Enhance system health metrics collection with configured network stats

* refactor: Enhance SystemHealth structure with CPU details and uptime metrics

* refactor: Remove commented-out code for ingress interface in SystemHealth

* refactor: Update SystemHealthMetrics to include system information and adjust CPU usage calculation

* add: Add new webpack and CSS files for improved styling and functionality

* refactor: Update health API endpoint paths for consistency

* update: dashbrad

* update: Update dashbroad

---------

Co-authored-by: ParrotXray <41143154@nfu.edu.tw>
2025-08-25 23:14:27 +08:00
505 changed files with 53079 additions and 5506 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
* text=auto eol=lf
*.mmdb filter=lfs diff=lfs merge=lfs -text

BIN
.github/images/architecture.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

105
.github/images/architecture.svg vendored Normal file
View File

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" width="1200" height="900">
<defs>
<style>
text { font-family: "Inter", "Segoe UI", "Noto Sans", sans-serif; fill: #e5e7eb; }
.title { font-size: 26px; font-weight: 700; fill: #f9fafb; }
.band-label { font-size: 11px; font-weight: 600; letter-spacing: 1.5px; fill: #9ca3af; }
.box-title { font-size: 14px; font-weight: 600; fill: #f3f4f6; }
.box-title-lg { font-size: 16px; font-weight: 700; fill: #f3f4f6; }
.box-sub { font-size: 12px; fill: #cbd5e1; }
.box-bullet { font-size: 12px; fill: #cbd5e1; }
.arrow-label { font-size: 11px; font-weight: 500; fill: #d1d5db; }
.arrow-label-bg { fill: #0f0f1a; }
.legend-label { font-size: 11px; fill: #9ca3af; }
</style>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 Z" fill="#60a5fa"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 Z" fill="#34d399"/>
</marker>
<marker id="arrow-orange" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 Z" fill="#fb923c"/>
</marker>
<marker id="arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 Z" fill="#9ca3af"/>
</marker>
<marker id="arrow-purple" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 Z" fill="#a78bfa"/>
</marker>
</defs>
<rect x="0" y="0" width="1200" height="900" fill="#0f0f1a"/>
<text class="title" x="600" y="46" text-anchor="middle">NetGuardia architecture</text>
<rect x="40" y="70" width="1120" height="80" rx="10" fill="#161627" stroke="#2a2a4a" stroke-width="1"/>
<text class="band-label" x="60" y="92">CONTROL PLANE</text>
<rect x="400" y="100" width="400" height="40" rx="8" fill="#1f1f36" stroke="#60a5fa" stroke-width="1.5"/>
<text class="box-title" x="600" y="126" text-anchor="middle">Web UI + REST / WebSocket API</text>
<rect x="40" y="170" width="1120" height="500" rx="10" fill="#161627" stroke="#2a2a4a" stroke-width="1"/>
<text class="band-label" x="60" y="192">USER SPACE — DETECTION &amp; RESPONSE</text>
<rect x="115" y="210" width="220" height="80" rx="8" fill="#1f1f36" stroke="#a78bfa" stroke-width="1.5"/>
<text class="box-title" x="225" y="244" text-anchor="middle">ML Inference</text>
<text class="box-sub" x="225" y="266" text-anchor="middle">tract-onnx · BYO model</text>
<rect x="365" y="210" width="220" height="80" rx="8" fill="#1f1f36" stroke="#a78bfa" stroke-width="1.5"/>
<text class="box-title" x="475" y="244" text-anchor="middle">Beaconing</text>
<text class="box-sub" x="475" y="266" text-anchor="middle">temporal CV</text>
<rect x="615" y="210" width="220" height="80" rx="8" fill="#1f1f36" stroke="#a78bfa" stroke-width="1.5"/>
<text class="box-title" x="725" y="244" text-anchor="middle">Correlation</text>
<text class="box-sub" x="725" y="266" text-anchor="middle">graph topology</text>
<rect x="865" y="210" width="220" height="80" rx="8" fill="#1f1f36" stroke="#a78bfa" stroke-width="1.5"/>
<text class="box-title" x="975" y="244" text-anchor="middle">Suricata</text>
<text class="box-sub" x="975" y="266" text-anchor="middle">eve.json ingest</text>
<rect x="300" y="350" width="600" height="130" rx="10" fill="#1f1f36" stroke="#60a5fa" stroke-width="2"/>
<text class="box-title-lg" x="600" y="378" text-anchor="middle">Detection Orchestrator</text>
<text class="box-bullet" x="340" y="410">• canonicalize attack type</text>
<text class="box-bullet" x="340" y="432">• fuse: 1 ∏(1 c_i)</text>
<text class="box-bullet" x="340" y="454">• WORM audit (SHA-256 chained)</text>
<rect x="300" y="520" width="600" height="130" rx="10" fill="#1f1f36" stroke="#fb923c" stroke-width="2"/>
<text class="box-title-lg" x="600" y="548" text-anchor="middle">SOAR Engine</text>
<text class="box-bullet" x="340" y="580">• playbook match</text>
<text class="box-bullet" x="340" y="602">• cooldown + dry-run</text>
<text class="box-bullet" x="340" y="624">• actions: block / rate-limit / webhook / email / telegram</text>
<rect x="40" y="690" width="1120" height="140" rx="10" fill="#161627" stroke="#2a2a4a" stroke-width="1"/>
<text class="band-label" x="60" y="712">KERNEL SPACE — eBPF / XDP</text>
<rect x="80" y="750" width="180" height="60" rx="8" fill="#1f1f36" stroke="#34d399" stroke-width="1.5"/>
<text class="box-title" x="170" y="787" text-anchor="middle">ACL</text>
<rect x="295" y="750" width="180" height="60" rx="8" fill="#1f1f36" stroke="#34d399" stroke-width="1.5"/>
<text class="box-title" x="385" y="787" text-anchor="middle">Rate Limit</text>
<rect x="510" y="750" width="180" height="60" rx="8" fill="#1f1f36" stroke="#34d399" stroke-width="1.5"/>
<text class="box-title" x="600" y="787" text-anchor="middle">Protocol Filter</text>
<rect x="725" y="750" width="180" height="60" rx="8" fill="#1f1f36" stroke="#34d399" stroke-width="1.5"/>
<text class="box-title" x="815" y="787" text-anchor="middle">Geo Block</text>
<rect x="940" y="750" width="180" height="60" rx="8" fill="#1f1f36" stroke="#34d399" stroke-width="1.5"/>
<text class="box-title" x="1030" y="787" text-anchor="middle">DNS Filter</text>
<line x1="262" y1="780" x2="293" y2="780" stroke="#34d399" stroke-width="1.8" marker-end="url(#arrow-green)"/>
<line x1="477" y1="780" x2="508" y2="780" stroke="#34d399" stroke-width="1.8" marker-end="url(#arrow-green)"/>
<line x1="692" y1="780" x2="723" y2="780" stroke="#34d399" stroke-width="1.8" marker-end="url(#arrow-green)"/>
<line x1="907" y1="780" x2="938" y2="780" stroke="#34d399" stroke-width="1.8" marker-end="url(#arrow-green)"/>
<line x1="590" y1="140" x2="590" y2="348" stroke="#9ca3af" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arrow-gray)"/>
<line x1="610" y1="348" x2="610" y2="142" stroke="#9ca3af" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arrow-gray)"/>
<rect class="arrow-label-bg" x="535" y="156" width="130" height="18" rx="2"/>
<text class="arrow-label" x="600" y="169" text-anchor="middle">admin &amp; live events</text>
<path d="M 225,290 L 225,320 L 420,340 L 420,350" fill="none" stroke="#60a5fa" stroke-width="1.8" marker-end="url(#arrow-blue)"/>
<path d="M 475,290 L 475,320 L 540,340 L 540,350" fill="none" stroke="#60a5fa" stroke-width="1.8" marker-end="url(#arrow-blue)"/>
<path d="M 725,290 L 725,320 L 660,340 L 660,350" fill="none" stroke="#60a5fa" stroke-width="1.8" marker-end="url(#arrow-blue)"/>
<path d="M 975,290 L 975,320 L 780,340 L 780,350" fill="none" stroke="#60a5fa" stroke-width="1.8" marker-end="url(#arrow-blue)"/>
<line x1="600" y1="480" x2="600" y2="520" stroke="#34d399" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect class="arrow-label-bg" x="612" y="488" width="135" height="18" rx="2"/>
<text class="arrow-label" x="680" y="501" text-anchor="middle">ThreatDetectedEvent</text>
<path d="M 320,650 L 320,680 L 115,680 L 115,745 L 170,745 L 170,750" fill="none" stroke="#fb923c" stroke-width="2" marker-end="url(#arrow-orange)"/>
<rect class="arrow-label-bg" x="130" y="670" width="170" height="18" rx="2"/>
<text class="arrow-label" x="215" y="683" text-anchor="middle">enforce (block / rate-limit)</text>
<path d="M 1095,750 L 1095,310 L 1005,310 L 1005,290" fill="none" stroke="#a78bfa" stroke-width="2" marker-end="url(#arrow-purple)"/>
<rect class="arrow-label-bg" x="1030" y="468" width="100" height="18" rx="2"/>
<text class="arrow-label" x="1080" y="481" text-anchor="middle">AF_XDP mirror</text>
<line x1="60" y1="870" x2="84" y2="870" stroke="#60a5fa" stroke-width="2"/>
<text class="legend-label" x="90" y="874">detection events</text>
<line x1="216" y1="870" x2="240" y2="870" stroke="#34d399" stroke-width="2"/>
<text class="legend-label" x="246" y="874">fused event / kernel pipeline</text>
<line x1="450" y1="870" x2="474" y2="870" stroke="#fb923c" stroke-width="2"/>
<text class="legend-label" x="480" y="874">enforcement action</text>
<line x1="618" y1="870" x2="642" y2="870" stroke="#a78bfa" stroke-width="2"/>
<text class="legend-label" x="648" y="874">AF_XDP mirror</text>
<line x1="756" y1="870" x2="780" y2="870" stroke="#9ca3af" stroke-width="2"/>
<text class="legend-label" x="786" y="874">admin / control</text>
</svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
.github/images/ui/access-control.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
.github/images/ui/account-management.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
.github/images/ui/api-keys.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
.github/images/ui/audit-log.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

BIN
.github/images/ui/auto-response.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
.github/images/ui/detection.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
.github/images/ui/dns-filter.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
.github/images/ui/drop-monitor.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
.github/images/ui/flow-trace.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
.github/images/ui/geoip-block.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
.github/images/ui/logs.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

BIN
.github/images/ui/map.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

BIN
.github/images/ui/protocol-filter.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
.github/images/ui/rate-limit.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
.github/images/ui/security-report.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
.github/images/ui/statistics.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

BIN
.github/images/ui/system-settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
.github/images/ui/system-status.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

119
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,119 @@
name: CI
on:
push:
branches: [master, dalaw2-dev]
pull_request:
branches: [master, dalaw2-dev]
env:
CARGO_TERM_COLOR: always
jobs:
build-and-test:
name: Build & Test
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve frontend submodule commit
id: frontend-ref
run: echo "sha=$(git rev-parse HEAD:net-guardia-frontend)" >> "$GITHUB_OUTPUT"
- name: Checkout frontend submodule
uses: actions/checkout@v4
with:
repository: DaLaw2/NetGuardia-FrontEnd
ref: ${{ steps.frontend-ref.outputs.sha }}
path: net-guardia-frontend
token: ${{ secrets.SUBMODULE_PAT }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
gcc m4 clang llvm \
libelf-dev zlib1g-dev pkg-config
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: net-guardia-frontend/package-lock.json
- name: Install frontend dependencies
run: npm ci
working-directory: net-guardia-frontend
- name: Install Rust stable toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install Rust nightly toolchain (for eBPF)
uses: dtolnay/rust-toolchain@nightly
with:
components: rust-src
# Ensure stable is the default so cargo check/test/clippy use stable.
# Nightly is only needed for the eBPF subprocess (which uses
# rust-toolchain.toml in ingress-ebpf/egress-ebpf).
- name: Set stable as default toolchain
run: rustup default stable
- name: Cache cargo registry and build artifacts
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: Install bpf-linker
run: |
cargo install cargo-binstall --locked 2>/dev/null || true
if command -v cargo-binstall &>/dev/null; then
cargo binstall bpf-linker --no-confirm --force || cargo install bpf-linker --locked
else
cargo install bpf-linker --locked
fi
ls -la "$HOME/.cargo/bin/bpf-linker"
timeout-minutes: 45
- name: cargo check default workspace members
run: cargo check
- name: cargo test default workspace members
run: cargo test
- name: cargo clippy default workspace members
run: cargo clippy -- -D warnings
- name: Frontend build
run: npm run build
working-directory: net-guardia-frontend
- name: Frontend tests
run: npm test
working-directory: net-guardia-frontend
integration-test:
name: Integration Test (placeholder)
runs-on: ubuntu-latest
needs: build-and-test
if: github.event_name == 'push' || github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Integration test reminder
run: |
echo "============================================"
echo " Integration tests are not run in CI."
echo " They require the podman test environment."
echo ""
echo " Run on the dev server:"
echo " bash /home/dalaw2/test_netguardia.sh"
echo "============================================"

40
.gitignore vendored
View File

@ -1,3 +1,6 @@
# Claude Code
.claude/
### https://raw.github.com/github/gitignore/master/Rust.gitignore
# Generated by Cargo
@ -10,3 +13,40 @@ target/
.idea
logs
TODO
.log
.txt
.csv
net-guardia/static/web
# Environment
.env
.env.local
# OS
.DS_Store
# Profiling
*.profraw
*.profdata
*.hex
.gstack/
interfaces.txt
traffic_log.csv
CLAUDE.md
AGENT.md
DESIGN.md
TODOS.md
VERSION
CHANGELOG.md
# Benchmark data/results (local only)
benchmark/
docs/
# SQLite database files
*.db
*.db-shm
*.db-wal

7
.gitmodules vendored Normal file
View File

@ -0,0 +1,7 @@
[submodule "net-guardia-frontend"]
path = net-guardia-frontend
url = https://github.com/DaLaw2/NetGuardia-FrontEnd.git
[submodule "net-guardia-trainer"]
path = net-guardia-trainer
url = https://github.com/ParrotXray/NetGuardia-Trainer.git
branch = dalaw2-dev

4303
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,114 @@
[workspace]
resolver = "2"
members = ["net-guardia", "net-guardia-common", "net-guardia-ingress-ebpf", "net-guardia-egress-ebpf"]
default-members = ["net-guardia", "net-guardia-common"]
members = ["net-guardia", "net-guardia-abi", "macros", "ingress-ebpf", "egress-ebpf", "net-guardia-cli"]
default-members = ["net-guardia", "net-guardia-abi", "net-guardia-cli"]
[workspace.dependencies]
aya = { version = "0.13.0", default-features = false }
aya-ebpf = { version = "0.1.1", default-features = false }
aya-log = { version = "0.2.1", default-features = false }
aya-log-ebpf = { version = "0.1.1", default-features = false }
# Local crates
net-guardia-abi = { path = "net-guardia-abi" }
macros = { path = "macros" }
anyhow = { version = "1", default-features = false }
cargo_metadata = { version = "0.19.0", default-features = false }
# `std` feature is currently required to build `clap`.
#
# See https://github.com/clap-rs/clap/blob/61f5ee5/clap_builder/src/lib.rs#L15.
clap = { version = "4.5.20", default-features = false, features = ["std"] }
env_logger = { version = "0.11.5", default-features = false }
libc = { version = "0.2.159", default-features = false }
log = { version = "0.4.22", default-features = false }
tokio = { version = "1.40.0", default-features = false }
which = { version = "7.0.0", default-features = false }
# eBPF - kernel side (pinned: aya-ebpf 0.1.2 was yanked, see aya-rs/aya#1400)
aya-ebpf = { version = "=0.1.1", default-features = false }
aya-log-ebpf = { version = "=0.1.0", default-features = false }
# eBPF - userspace side
aya = { version = "0.13.1", default-features = false }
aya-log = { version = "0.2.1", default-features = false }
network-types = { version = "0.2.0", default-features = false }
# XDP
xsk-rs = { version = "0.8.0", default-features = false }
libxdp-sys = { version = "0.2.4", features = ["use_cc_build", "use_precompiled_bpf"] }
# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
serde_yaml_ng = "0.10.0"
# Async runtime
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "io-std", "fs", "signal"] }
tokio-util = { version = "0.7", features = ["io"] }
# Web framework
actix = "0.13.5"
actix-web = "4.13.0"
actix-cors = "0.7.1"
actix-ws = "0.4.0"
actix-multipart = "0.7"
actix-files = "0.6"
tokio-tungstenite = "0.29.0"
zip = "8.6.0"
# Logging / tracing
tracing = "0.1.44"
tracing-appender = "0.2.4"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "registry"] }
# ML
tract-onnx = "0.22.1"
# Utilities
parking_lot = "0.12.5"
libc = { version = "0.2.183", default-features = false }
thiserror = "2.0.18"
futures-util = "0.3.32"
crossbeam = "0.8.4"
# System
sysinfo = "0.39.0"
maxminddb = "0.28.1"
ipnetwork = "0.21.1"
lru = "0.18.0"
rusqlite = { version = "0.39", features = ["bundled-sqlcipher"] }
async-sqlite = { version = "0.5.7", default-features = false, features = ["bundled-sqlcipher"] }
argon2 = "0.5"
rand = "0.10.1"
ed25519-dalek = { version = "2", features = ["std", "rand_core"] }
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
uuid = { version = "1", features = ["v4"] }
rust-embed = "8.11.0"
mime_guess = "2.0.5"
url = "2.5.8"
toml = "1.0.7"
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1-rustls-tls"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
reqwest = { version = "0.13.3", default-features = false, features = ["json", "rustls"] }
async-trait = "0.1"
dashmap = "6"
arc-swap = "1"
moka = { version = "0.12", features = ["sync"] }
notify = "8.2.0"
sha2 = "0.11.0"
hmac = "0.13.0"
aes-gcm = "0.10"
hkdf = "0.13.0"
sd-notify = "0.5.0"
# Build dependencies
cargo_metadata = { version = "0.23.1", default-features = false }
which = "8.0.2"
dotenvy = "0.15.7"
# Proc macro
proc-macro2 = "1.0.106"
quote = "1.0.45"
syn = { version = "2.0.117", features = ["full"] }
[profile.dev]
panic = "abort"
panic = "unwind"
[profile.release]
panic = "abort"
#opt-level = 3
#lto = true
#strip = true
#debug = false
#overflow-checks = false
panic = "unwind"
opt-level = 3
lto = "thin"
strip = true
[profile.release.package.net-guardia-ingress-ebpf]
[profile.release.package.ingress-ebpf]
debug = 2
codegen-units = 1
[profile.release.package.net-guardia-egress-ebpf]
[profile.release.package.egress-ebpf]
debug = 2
codegen-units = 1

View File

@ -1,45 +1,76 @@
# NetGuardia 🛡️
# NetGuardia
## 📌 專案簡介
Inline network security platform built on eBPF/XDP. Combines ONNX-based ML, temporal beaconing, correlation heuristics, and Suricata `eve.json` alerts in one fusion path, drives SOAR playbooks, and writes decisions into a WORM audit chain.
**NetGuardia** 是一個結合 eBPF XDP 與深度學習模型的實體網路防護裝置,運行於 Raspberry Pi 5 與 Intel i350 T2 網卡。本專案旨在提供高效能、低延遲的網路防護解決方案,適用於家庭與中小型企業環境。
## Stack
## 🔧 核心技術
- **Data plane** — eBPF / XDP / AF_XDP (aya, xsk-rs)
- **Detection** — Rust + tract-onnx for ML, custom temporal / graph engines, Suricata `eve.json` ingest
- **Control plane** — actix-web REST + WebSocket, SQLite + SQLCipher, argon2 / JWT / CSRF, per-playbook SOAR
- **Frontend** — Vue 3 + Pinia + Vue-i18n (en / zh-TW / zh-CN / ja)
- **Architecture** — hexagonal-ish Rust workspace: `domain/` · `interface/` · `core/` · `adapter/` · `infrastructure/`
- **eBPF XDP 技術** - 實現高效能網路封包處理,直接在資料連結層操作
- **深度學習模型** - 識別與預測潛在網路攻擊,提供智慧化防護
- **嵌入式硬體整合** - 結合 Raspberry Pi 5 與 Intel i350 T2提供獨立且強大的網路防護功能
## Screens
## 🧩 功能模組
<table>
<tr>
<td><img src=".github/images/ui/statistics.png" alt="Traffic statistics"/><br><sub>Traffic statistics (per-IP bytes/packets)</sub></td>
<td><img src=".github/images/ui/map.png" alt="Geo map"/><br><sub>Live geographic flow map</sub></td>
<td><img src=".github/images/ui/drop-monitor.png" alt="Drop monitor"/><br><sub>Real-time drop monitor</sub></td>
</tr>
<tr>
<td><img src=".github/images/ui/detection.png" alt="Threat detection"/><br><sub>Fused threat detection + ML status</sub></td>
<td><img src=".github/images/ui/access-control.png" alt="Access control"/><br><sub>IPv4/IPv6 allow + block lists</sub></td>
<td><img src=".github/images/ui/geoip-block.png" alt="GeoIP block"/><br><sub>GeoIP country block</sub></td>
</tr>
<tr>
<td><img src=".github/images/ui/dns-filter.png" alt="DNS filter"/><br><sub>DNS blacklist</sub></td>
<td><img src=".github/images/ui/rate-limit.png" alt="Rate limit"/><br><sub>Per-class DDoS rate limits</sub></td>
<td><img src=".github/images/ui/protocol-filter.png" alt="Protocol filter"/><br><sub>HTTP / SSH service rules</sub></td>
</tr>
<tr>
<td><img src=".github/images/ui/auto-response.png" alt="SOAR"/><br><sub>SOAR playbooks + dry-run</sub></td>
<td><img src=".github/images/ui/security-report.png" alt="Security report"/><br><sub>Security report (PDF / email)</sub></td>
<td><img src=".github/images/ui/audit-log.png" alt="Audit log"/><br><sub>WORM-chained audit log</sub></td>
</tr>
<tr>
<td><img src=".github/images/ui/account-management.png" alt="Accounts"/><br><sub>Users + groups + RBAC</sub></td>
<td><img src=".github/images/ui/api-keys.png" alt="API keys"/><br><sub>API keys</sub></td>
<td><img src=".github/images/ui/flow-trace.png" alt="Flow trace"/><br><sub>Rotated flow recording</sub></td>
</tr>
<tr>
<td><img src=".github/images/ui/logs.png" alt="Logs"/><br><sub>Live + archived logs</sub></td>
<td><img src=".github/images/ui/system-status.png" alt="System status"/><br><sub>CPU / memory / NIC counters</sub></td>
<td><img src=".github/images/ui/system-settings.png" alt="System settings"/><br><sub>Mode / theme / HTTP / engine</sub></td>
</tr>
</table>
### 📊 儀表板總覽 (Dashboard)
![儀表板介面](./github/dashboard.png)
- 即時網路流量監控與視覺化統計
- 近期流量大小統計與趨勢圖
## Architecture
### 📈 詳細流量統計 (Statistics)
![流量統計介面](./github/statistics.png)
- 各 IP 位址詳細流量使用情況
![NetGuardia architecture](.github/images/architecture.png)
### 🔒 網路存取控制 (Access Control)
![存取控制介面](./github/accessControl.png)
- IPv4/IPv6 黑白名單管理
- 精確的連接埠層級存取控制
## Requirements
### 🤖 AI 攻擊偵測 (AI Detection)
![AI 攻擊偵測介面](./github/aiDetection.png)
- 基於 AI 的攻擊偵測引擎
Linux kernel with eBPF **and** a NIC driver that implements AF_XDP on that kernel. No single "minimum kernel" — it depends on the NIC.
## ✨ 系統特色
| Driver | NIC family | Min kernel for AF_XDP |
|---|---|---|
| `mlx5` | Mellanox ConnectX-4/5/6/7 | 5.x |
| `ixgbe` | Intel 82599, X520, X540, X550 | 5.x |
| `i40e` | Intel X710, XL710, XXV710 | 5.x |
| `ice` | Intel E810 | 5.5+ |
| `igb` | Intel i350 T2 (reference HW) | **6.17** |
| `igc` | Intel I225/I226 | 6.x |
| `virtio_net` | QEMU/KVM | varies |
- **⚡ 高效能** - 低延遲封包處理,最小化網路效能影響
- **👥 易用性** - 跨平台 Web 管理介面,直覺操作
- **🔄 可靠性** - 硬體加速處理,確保穩定運行
- **📦 可擴展** - 模組化設計,支援功能擴展
Check with `ethtool -i <iface>` before deploying. 8 GB RAM minimum, 16 GB+ for high-traffic.
## 💻 安裝需求
## Build
- Raspberry Pi 5 (建議 8GB RAM 版本)
- 雙孔網卡(能支援 XDP native/offload 更好)
- 32GB 以上 microSD 卡 (建議 Class 10 以上)
- 5V/3A 以上電源供應器
```sh
cargo build --release --package net-guardia
sudo ./target/release/net-guardia
# open http://<host>:8080 — setup wizard issues the admin password on first boot
```
Systemd unit: [`deploy/netguardia.service`](deploy/netguardia.service).

View File

@ -1,7 +0,0 @@
[Config]
ingress_ifindex = "enp1s0f0" # nic name
egress_ifindex = "enp1s0f1" # nic name
management_ifindex = "wlan0" # nic name
alert_path = "/tmp/alert"
http_server_bind_port = 8080 # port
refresh_interval = 1 # seconds

View File

@ -0,0 +1,25 @@
FROM rockylinux:10
RUN dnf install -y epel-release && \
dnf install -y --allowerasing \
nmap \
tcpdump \
net-tools \
iputils \
iproute \
ncat \
curl wget \
httpd \
openssh-server \
python3 python3-pip \
&& dnf clean all
RUN pip3 install scapy requests
RUN echo "NetGuardia Endpoint" > /var/www/html/index.html && \
ssh-keygen -A
COPY scripts/*.sh /opt/scripts/
RUN chmod +x /opt/scripts/*.sh
CMD ["sleep", "infinity"]

View File

@ -0,0 +1,41 @@
FROM rockylinux:10
RUN dnf install -y epel-release && \
crb enable && \
dnf install -y --allowerasing \
clang llvm \
bpftool \
iproute iproute-tc \
elfutils-libelf-devel \
zlib-devel \
libbpf-devel \
kernel-headers \
tcpdump \
net-tools \
iputils \
curl wget \
git \
gh \
vim \
ethtool \
nodejs24 \
nodejs24-npm \
m4 \
make pkg-config \
openssl-devel \
&& dnf clean all
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.95.0 \
&& /root/.cargo/bin/rustup component add clippy rustfmt --toolchain 1.95.0 \
&& /root/.cargo/bin/rustup toolchain install nightly --component rust-src \
&& /root/.cargo/bin/rustup default 1.95.0
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install bpf-linker --version 0.10.3 --locked
RUN ln -s /usr/bin/node-24 /usr/local/bin/node && \
ln -s /usr/bin/npm-24 /usr/local/bin/npm && \
ln -s /usr/bin/npx-24 /usr/local/bin/npx
WORKDIR /root/NetGuardia
CMD ["sleep", "infinity"]

View File

@ -0,0 +1,10 @@
FROM rockylinux:10
RUN dnf install -y --allowerasing \
iproute \
iputils \
net-tools \
tcpdump \
&& dnf clean all
CMD ["sleep", "infinity"]

View File

@ -0,0 +1,78 @@
version: "3"
services:
netguardia:
build:
context: ..
dockerfile: compose/Containerfile.netguardia
image: netguardia:latest
container_name: netguardia
hostname: netguardia
privileged: true
security_opt:
- seccomp=unconfined
ulimits:
memlock:
soft: -1
hard: -1
environment:
NETGUARDIA_DB_KEY: netguardia-dev-db-key
NETGUARDIA_SECRETS_KEY: netguardia-dev-secrets-key
dns:
- 10.10.3.1
- 8.8.8.8
networks:
mgmt-net:
ipv4_address: 10.10.3.10
ports:
- "8080:8080"
volumes:
- /home/dalaw2/NetGuardia:/root/NetGuardia:z
- /sys/fs/bpf:/sys/fs/bpf:rw
- /sys/kernel/debug:/sys/kernel/debug:ro
router:
build:
context: ..
dockerfile: compose/Containerfile.router
image: netguardia-router:latest
container_name: router
hostname: router
cap_add:
- NET_ADMIN
- NET_RAW
network_mode: none
external:
build:
context: ..
dockerfile: compose/Containerfile.endpoint
image: netguardia-endpoint:latest
container_name: external
hostname: external
cap_add:
- NET_RAW
- NET_ADMIN
network_mode: none
command: ["/opt/scripts/traffic-external.sh"]
internal:
build:
context: ..
dockerfile: compose/Containerfile.endpoint
image: netguardia-endpoint:latest
container_name: internal
hostname: internal
cap_add:
- NET_RAW
- NET_ADMIN
network_mode: none
command: ["/opt/scripts/traffic-internal.sh"]
networks:
mgmt-net:
driver: bridge
ipam:
config:
- subnet: 10.10.3.0/24
gateway: 10.10.3.1

14
deploy/logrotate.conf Normal file
View File

@ -0,0 +1,14 @@
/var/log/netguardia/*.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
maxsize 500M
create 0640 root root
sharedscripts
postrotate
systemctl reload netguardia.service 2>/dev/null || true
endscript
}

38
deploy/netguardia.service Normal file
View File

@ -0,0 +1,38 @@
[Unit]
Description=NetGuardia Network Security Gateway
Documentation=https://github.com/dalaw2/NetGuardia
After=network.target
Wants=network.target
[Service]
Type=notify
ExecStart=/opt/netguardia/bin/net-guardia
WorkingDirectory=/opt/netguardia
Restart=on-failure
RestartSec=5
# Watchdog: service must notify systemd within this interval or be killed
WatchdogSec=30
# Security hardening
NoNewPrivileges=false
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/netguardia /var/log/netguardia /var/lib/netguardia
PrivateTmp=yes
# Resource limits
LimitNOFILE=65536
LimitMEMLOCK=infinity
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=netguardia
# Environment
Environment=RUST_LOG=info
Environment=CONFIG_PATH=/opt/netguardia/config.toml
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,46 @@
#cloud-config
autoinstall:
version: 1
locale: en_US.UTF-8
keyboard:
layout: us
identity:
hostname: netguardia
username: netguardia
# Password: netguardia (mkpasswd --method=SHA-512)
password: "$6$rounds=4096$randomsalt$PLACEHOLDER_HASH"
ssh:
install-server: true
allow-pw: true
storage:
layout:
name: lvm
sizing-policy: all
network:
version: 2
ethernets:
ens3:
dhcp4: true
packages:
- curl
- net-tools
- iproute2
- linux-tools-common
late-commands:
# Create required directories
- mkdir -p /target/opt/netguardia/bin
- mkdir -p /target/var/log/netguardia
# Enable serial console for headless access
- >-
curtin in-target -- systemctl enable serial-getty@ttyS0.service
final_message: |
NetGuardia image provisioning complete.
The HTTP setup wizard starts automatically on port 8080.

View File

@ -0,0 +1,262 @@
packer {
required_plugins {
qemu = {
source = "github.com/hashicorp/qemu"
version = ">= 1.1.0"
}
virtualbox = {
source = "github.com/hashicorp/virtualbox"
version = ">= 1.0.0"
}
}
}
# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------
variable "ubuntu_iso_url" {
type = string
default = "https://releases.ubuntu.com/24.04/ubuntu-24.04-live-server-amd64.iso"
}
variable "ubuntu_iso_checksum" {
type = string
description = "SHA-256 checksum of the Ubuntu 24.04 Server ISO (e.g. sha256:abcdef...). Must be provided explicitly."
validation {
condition = can(regex("^sha256:[0-9a-fA-F]{64}$", var.ubuntu_iso_checksum))
error_message = "ubuntu_iso_checksum must be a valid SHA-256 checksum in the form 'sha256:<64 hex chars>'. Do not use 'sha256:none'."
}
}
variable "netguardia_binary" {
type = string
default = "../target/release/net-guardia"
description = "Path to the pre-built NetGuardia binary."
}
variable "ssh_username" {
type = string
default = "netguardia"
}
variable "ssh_password" {
type = string
default = "netguardia"
sensitive = true
}
variable "disk_size" {
type = string
default = "20480"
description = "Virtual disk size in MB."
}
variable "memory" {
type = string
default = "2048"
}
variable "cpus" {
type = string
default = "2"
}
variable "accelerator" {
type = string
default = "kvm"
description = "QEMU accelerator: 'kvm' (default) or 'none' for environments without KVM support."
validation {
condition = contains(["kvm", "none"], var.accelerator)
error_message = "accelerator must be 'kvm' or 'none'."
}
}
# ---------------------------------------------------------------------------
# Source: QEMU (produces QCOW2)
# ---------------------------------------------------------------------------
source "qemu" "netguardia" {
iso_url = var.ubuntu_iso_url
iso_checksum = var.ubuntu_iso_checksum
output_directory = "output-qcow2"
format = "qcow2"
disk_size = var.disk_size
memory = var.memory
cpus = var.cpus
headless = true
ssh_username = var.ssh_username
ssh_password = var.ssh_password
ssh_timeout = "30m"
shutdown_command = "echo '${var.ssh_password}' | sudo -S shutdown -P now"
boot_wait = "5s"
http_directory = "cloud-init"
boot_command = [
"c<wait>",
"linux /casper/vmlinuz autoinstall ds='nocloud-net;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/' ",
"--- <enter><wait>",
"initrd /casper/initrd<enter><wait>",
"boot<enter>"
]
vm_name = "netguardia"
net_device = "virtio-net"
disk_interface = "virtio"
accelerator = var.accelerator
}
# ---------------------------------------------------------------------------
# Source: VirtualBox (produces OVA)
# ---------------------------------------------------------------------------
source "virtualbox-iso" "netguardia" {
iso_url = var.ubuntu_iso_url
iso_checksum = var.ubuntu_iso_checksum
output_directory = "output-ova"
format = "ova"
disk_size = var.disk_size
memory = var.memory
cpus = var.cpus
headless = true
guest_os_type = "Ubuntu_64"
ssh_username = var.ssh_username
ssh_password = var.ssh_password
ssh_timeout = "30m"
shutdown_command = "echo '${var.ssh_password}' | sudo -S shutdown -P now"
boot_wait = "5s"
http_directory = "cloud-init"
boot_command = [
"c<wait>",
"linux /casper/vmlinuz autoinstall ds='nocloud-net;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/' ",
"--- <enter><wait>",
"initrd /casper/initrd<enter><wait>",
"boot<enter>"
]
vboxmanage = [
["modifyvm", "{{ .Name }}", "--nic2", "intnet"],
["modifyvm", "{{ .Name }}", "--intnet2", "netguardia-internal"]
]
}
# ---------------------------------------------------------------------------
# Build
# ---------------------------------------------------------------------------
build {
sources = [
"source.qemu.netguardia",
"source.virtualbox-iso.netguardia"
]
# ------ KVM fallback warning ------
provisioner "shell" {
inline = [
"if [ '${var.accelerator}' = 'none' ]; then",
" echo '⚠ WARNING: Building without KVM acceleration. This will be significantly slower.'",
" echo '⚠ Set accelerator=kvm for production builds.'",
"fi"
]
}
# ------ Upload artifacts ------
provisioner "file" {
source = var.netguardia_binary
destination = "/tmp/net-guardia"
}
provisioner "file" {
source = "../deploy/scripts/install.sh"
destination = "/tmp/install.sh"
}
provisioner "file" {
source = "../deploy/netguardia.service"
destination = "/tmp/netguardia.service"
}
provisioner "file" {
source = "../deploy/logrotate.conf"
destination = "/tmp/logrotate.conf"
}
provisioner "file" {
source = "../deploy/setup-wizard.sh"
destination = "/tmp/setup-wizard.sh"
}
# ------ Debug binary gate ------
provisioner "shell" {
inline = [
"set -e",
"echo 'Checking binary is not a debug build...'",
"if file /tmp/net-guardia | grep -q 'not stripped'; then",
" echo 'FATAL: Binary is a debug build (not stripped). Use a release build for VM images.'",
" exit 1",
"fi",
"echo 'Binary check passed: stripped release build.'"
]
}
# ------ Install runtime dependencies (SQLCipher needs OpenSSL) ------
provisioner "shell" {
inline = [
"set -e",
"if command -v apt-get &>/dev/null; then",
" sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libssl3",
"elif command -v dnf &>/dev/null; then",
" sudo dnf install -y openssl-libs",
"fi"
]
}
# ------ Install via install.sh --local ------
provisioner "shell" {
inline = [
"set -e",
"chmod +x /tmp/install.sh",
"# Lay out deploy dir structure so install.sh can find service/logrotate files",
"sudo mkdir -p /tmp/deploy/scripts",
"cp /tmp/install.sh /tmp/deploy/scripts/install.sh",
"cp /tmp/netguardia.service /tmp/deploy/netguardia.service",
"cp /tmp/logrotate.conf /tmp/deploy/logrotate.conf",
"sudo /tmp/deploy/scripts/install.sh --local /tmp/net-guardia",
"# Install setup wizard",
"sudo install -m 0755 /tmp/setup-wizard.sh /opt/netguardia/bin/setup-wizard.sh",
"# Configure first-boot setup wizard via rc.local",
"sudo tee /etc/rc.local > /dev/null << 'RCEOF'",
"#!/bin/bash",
"if [ ! -f /opt/netguardia/config.toml ]; then",
" /opt/netguardia/bin/setup-wizard.sh",
"fi",
"exit 0",
"RCEOF",
"sudo chmod +x /etc/rc.local"
]
}
# ------ Final cleanup ------
provisioner "shell" {
inline = [
"sudo apt-get -y autoremove",
"sudo apt-get -y clean",
"sudo rm -rf /tmp/* /var/tmp/*",
"sudo truncate -s 0 /var/log/syslog",
"history -c"
]
}
}

522
deploy/scripts/dev.sh Executable file
View File

@ -0,0 +1,522 @@
#!/usr/bin/env bash
# Build the NetGuardia development containers and inline veth topology.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ROOT_DIR="$(cd "$DEPLOY_DIR/.." && pwd)"
BASE_COMPOSE_FILE="$DEPLOY_DIR/compose/podman-compose.yml"
COMPOSE_FILE="/tmp/netguardia-compose-$$.yml"
COMPOSE_PROJECT="compose"
LOG_FILE="/tmp/netguardia-dev-$(date +%Y%m%d-%H%M%S).log"
VERBOSE=0
CLEANUP_FIRST=1
RT=""
declare -a RT_CMD=()
declare -a COMPOSE_CMD=()
info() {
printf '[INFO] %s\n' "$*"
}
warn() {
printf '[WARN] %s\n' "$*" >&2
}
fatal() {
printf '[ERROR] %s\n' "$*" >&2
exit 1
}
usage() {
cat <<EOF
Usage: sudo bash deploy/scripts/dev.sh [--verbose] [--no-cleanup]
Options:
--verbose Print compose build/up output in addition to writing the log.
--no-cleanup Skip the default preflight cleanup of old containers/veth links.
-h, --help Show this help.
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--verbose)
VERBOSE=1
shift
;;
--no-cleanup)
CLEANUP_FIRST=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
fatal "Unknown argument: $1"
;;
esac
done
}
cleanup_netns_links() {
rm -f \
/var/run/netns/external \
/var/run/netns/internal \
/var/run/netns/router \
/var/run/netns/netguardia
}
cleanup_temp_files() {
cleanup_netns_links
rm -f "$COMPOSE_FILE"
}
trap cleanup_temp_files EXIT
require_root() {
[[ "$(id -u)" -eq 0 ]] || fatal "dev.sh must run as root. Use: sudo bash deploy/scripts/dev.sh"
}
require_linux_host() {
[[ "$(uname -s)" == "Linux" ]] || fatal "dev.sh supports Linux hosts only."
if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then
fatal "WSL2 is not supported for this XDP/AF_XDP development topology."
fi
}
package_manager() {
if command -v dnf >/dev/null 2>&1; then
printf 'dnf'
elif command -v apt-get >/dev/null 2>&1; then
printf 'apt-get'
elif command -v zypper >/dev/null 2>&1; then
printf 'zypper'
elif command -v pacman >/dev/null 2>&1; then
printf 'pacman'
fi
}
package_for_command() {
local manager="$1"
local command_name="$2"
case "$manager:$command_name" in
dnf:ip) printf 'iproute' ;;
dnf:ping) printf 'iputils' ;;
dnf:ethtool) printf 'ethtool' ;;
dnf:curl) printf 'curl' ;;
dnf:ln|dnf:mkdir|dnf:rm|dnf:uname) printf 'coreutils' ;;
apt-get:ip) printf 'iproute2' ;;
apt-get:ping) printf 'iputils-ping' ;;
apt-get:ethtool) printf 'ethtool' ;;
apt-get:curl) printf 'curl' ;;
apt-get:ln|apt-get:mkdir|apt-get:rm|apt-get:uname) printf 'coreutils' ;;
zypper:ip) printf 'iproute2' ;;
zypper:ping) printf 'iputils' ;;
zypper:ethtool) printf 'ethtool' ;;
zypper:curl) printf 'curl' ;;
zypper:ln|zypper:mkdir|zypper:rm|zypper:uname) printf 'coreutils' ;;
pacman:ip) printf 'iproute2' ;;
pacman:ping) printf 'iputils' ;;
pacman:ethtool) printf 'ethtool' ;;
pacman:curl) printf 'curl' ;;
pacman:ln|pacman:mkdir|pacman:rm|pacman:uname) printf 'coreutils' ;;
esac
}
append_unique() {
local value="$1"
shift
local existing
for existing in "$@"; do
[[ "$existing" == "$value" ]] && return 1
done
return 0
}
install_packages() {
local manager="$1"
shift
case "$manager" in
dnf)
dnf install -y "$@"
;;
apt-get)
DEBIAN_FRONTEND=noninteractive apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
;;
zypper)
zypper --non-interactive install "$@"
;;
pacman)
pacman -Sy --noconfirm "$@"
;;
*)
return 1
;;
esac
}
manual_install_command() {
local manager="$1"
shift
case "$manager" in
dnf) printf 'dnf install -y %s\n' "$*" ;;
apt-get) printf 'apt-get update && apt-get install -y %s\n' "$*" ;;
zypper) printf 'zypper --non-interactive install %s\n' "$*" ;;
pacman) printf 'pacman -Sy --noconfirm %s\n' "$*" ;;
*) printf 'Install packages manually: %s\n' "$*" ;;
esac
}
check_host_tools() {
local required_commands=(ip ping ethtool curl ln mkdir rm uname)
local missing_commands=()
local packages=()
local command_name manager package answer
for command_name in "${required_commands[@]}"; do
if ! command -v "$command_name" >/dev/null 2>&1; then
missing_commands+=("$command_name")
fi
done
if ((${#missing_commands[@]} == 0)); then
info "Host tools OK"
return 0
fi
manager="$(package_manager || true)"
[[ -n "$manager" ]] || fatal "Missing host commands: ${missing_commands[*]}. No supported package manager found."
for command_name in "${missing_commands[@]}"; do
package="$(package_for_command "$manager" "$command_name")"
[[ -n "$package" ]] || fatal "No package mapping for missing command '$command_name' on $manager."
if append_unique "$package" "${packages[@]}"; then
packages+=("$package")
fi
done
warn "Missing host commands: ${missing_commands[*]}"
warn "Package manager: $manager"
warn "Packages to install: ${packages[*]}"
if [[ ! -t 0 ]]; then
manual_install_command "$manager" "${packages[@]}" >&2
fatal "Non-interactive shell; refusing to install packages without consent."
fi
read -r -p "Install missing packages? [y/N] " answer
case "$answer" in
y|Y|yes|YES)
install_packages "$manager" "${packages[@]}"
;;
*)
manual_install_command "$manager" "${packages[@]}" >&2
fatal "Required host packages were not installed."
;;
esac
}
runtime_install_hint() {
cat >&2 <<'EOF'
Install a supported container runtime first.
Examples:
dnf install -y podman podman-compose
apt-get install -y podman podman-compose
apt-get install -y docker.io docker-compose-plugin
EOF
}
check_docker_supported() {
local context security_options operating_system
context="$(docker context show 2>/dev/null || true)"
if [[ "$context" == "desktop-linux" ]]; then
fatal "Docker Desktop is not supported for this XDP/netns topology."
fi
operating_system="$(docker info --format '{{.OperatingSystem}}' 2>/dev/null || true)"
if [[ "$operating_system" == *"Docker Desktop"* ]]; then
fatal "Docker Desktop is not supported for this XDP/netns topology."
fi
security_options="$(docker info --format '{{json .SecurityOptions}}' 2>/dev/null || true)"
if grep -qi rootless <<<"$security_options"; then
fatal "Rootless Docker is not supported for this privileged XDP/netns topology."
fi
}
detect_runtime() {
if command -v podman-compose >/dev/null 2>&1 && command -v podman >/dev/null 2>&1; then
RT="podman"
RT_CMD=(podman)
COMPOSE_CMD=(podman-compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE")
elif command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
check_docker_supported
RT="docker"
RT_CMD=(docker)
COMPOSE_CMD=(docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE")
else
runtime_install_hint
fatal "No supported runtime found. Need podman + podman-compose or docker + docker compose."
fi
info "Runtime: $RT"
}
generate_compose_file() {
local yaml_deploy
local yaml_root
[[ -f "$BASE_COMPOSE_FILE" ]] || fatal "Compose file not found: $BASE_COMPOSE_FILE"
yaml_deploy="${DEPLOY_DIR//\'/\'\'}"
yaml_root="${ROOT_DIR//\'/\'\'}"
: >"$COMPOSE_FILE"
while IFS= read -r line; do
case "$line" in
" context: ..")
printf " context: '%s'\n" "$yaml_deploy" >>"$COMPOSE_FILE"
;;
" - /home/dalaw2/NetGuardia:/root/NetGuardia:z")
printf " - '%s:/root/NetGuardia:z'\n" "$yaml_root" >>"$COMPOSE_FILE"
;;
*)
printf '%s\n' "$line" >>"$COMPOSE_FILE"
;;
esac
done <"$BASE_COMPOSE_FILE"
}
run_logged() {
local label="$1"
shift
info "$label"
if ((VERBOSE)); then
"$@" 2>&1 | tee -a "$LOG_FILE"
elif [[ -t 1 ]]; then
run_with_spinner "$label" "$@"
elif ! "$@" >>"$LOG_FILE" 2>&1; then
warn "$label failed. Last log lines:"
tail -n 80 "$LOG_FILE" >&2 || true
fatal "Full log: $LOG_FILE"
fi
}
run_with_spinner() {
local label="$1"
shift
local pid status
"$@" >>"$LOG_FILE" 2>&1 &
pid=$!
spinner "$pid" "$label"
set +e
wait "$pid"
status=$?
set -e
clear_spinner_line
if ((status != 0)); then
warn "$label failed. Last log lines:"
tail -n 80 "$LOG_FILE" >&2 || true
fatal "Full log: $LOG_FILE"
fi
}
spinner() {
local pid="$1"
local label="$2"
local frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local i=0
local frame
local started_at=$SECONDS
while kill -0 "$pid" 2>/dev/null; do
frame="${frames:i++%${#frames}:1}"
printf '\r%s %s... %02ds' "$frame" "$label" "$((SECONDS - started_at))"
sleep 0.12
done
}
clear_spinner_line() {
printf '\r\033[K'
}
runtime_rm_containers() {
"${RT_CMD[@]}" rm -f netguardia router external internal >/dev/null 2>&1 || true
}
delete_host_link() {
local link_name="$1"
ip link del "$link_name" >/dev/null 2>&1 || true
}
preflight_cleanup() {
((CLEANUP_FIRST)) || return 0
info "Cleaning old development topology"
runtime_rm_containers
cleanup_netns_links
delete_host_link ext-eth0
delete_host_link rtr-ext
delete_host_link rtr-int
delete_host_link ng-ext
delete_host_link int-eth0
delete_host_link ng-int
}
container_pid() {
"${RT_CMD[@]}" inspect --format '{{.State.Pid}}' "$1"
}
link_netns() {
local container="$1"
local pid
pid="$(container_pid "$container")"
[[ -n "$pid" && "$pid" != "0" ]] || fatal "Container '$container' is not running."
ln -sf "/proc/$pid/ns/net" "/var/run/netns/$container"
}
link_container_namespaces() {
mkdir -p /var/run/netns
link_netns external
link_netns internal
link_netns router
link_netns netguardia
}
netns() {
ip netns exec "$@"
}
disable_offload() {
local namespace="$1"
local interface="$2"
netns "$namespace" ethtool -K "$interface" tx off rx off >/dev/null 2>&1 || true
}
create_topology() {
info "Creating inline veth topology"
ip link add ext-eth0 type veth peer name rtr-ext
ip link set ext-eth0 netns external
ip link set rtr-ext netns router
netns external ip link set lo up
netns external ip link set ext-eth0 up
netns external ip addr add 10.10.1.2/24 dev ext-eth0
for i in 3 4 5 6 7; do
netns external ip addr add "10.10.1.$i/24" dev ext-eth0
done
netns external ip route replace default via 10.10.1.1
netns router ip link set lo up
netns router ip link set rtr-ext up
netns router ip addr add 10.10.1.1/24 dev rtr-ext
ip link add rtr-int type veth peer name ng-ext
ip link set rtr-int netns router
ip link set ng-ext netns netguardia
ip link add int-eth0 type veth peer name ng-int
ip link set int-eth0 netns internal
ip link set ng-int netns netguardia
netns router ip link set rtr-int up
netns router ip addr add 10.10.2.1/24 dev rtr-int
netns router sh -c 'echo 1 > /proc/sys/net/ipv4/ip_forward'
netns internal ip link set lo up
netns internal ip link set int-eth0 up
netns internal ip addr add 10.10.2.2/24 dev int-eth0
for i in 3 4 5 6; do
netns internal ip addr add "10.10.2.$i/24" dev int-eth0
done
netns internal ip route replace default via 10.10.2.1
netns netguardia ip link set ng-ext up
netns netguardia ip link set ng-int up
disable_offload router rtr-int
disable_offload router rtr-ext
disable_offload internal int-eth0
disable_offload external ext-eth0
disable_offload netguardia ng-ext
disable_offload netguardia ng-int
}
write_interface_mapping() {
cat >/tmp/netguardia_interfaces.txt <<'IEOF'
# NetGuardia interface mapping - realistic inline deployment
# Router handles L3 (10.10.1.0/24 <-> 10.10.2.0/24)
# NetGuardia inline on 10.10.2.0/24 (no IP, no bridge)
# ng-ext - XDP ingress (router side, attached to rtr-int peer)
# ng-int - XDP egress (internal side, attached to int-eth0 peer)
# XSK forwards packets: ng-ext RX -> ng-int TX and ng-int RX -> ng-ext TX
# Management: eth0 (10.10.3.10)
IEOF
"${RT_CMD[@]}" cp /tmp/netguardia_interfaces.txt netguardia:/root/NetGuardia/interfaces.txt >/dev/null 2>&1 || true
}
connectivity_check() {
local external_router="FAIL"
if netns external ping -c 1 -W 2 10.10.1.1 >/dev/null 2>&1; then
external_router="OK"
fi
info "Connectivity: external -> router: $external_router"
}
print_summary() {
cat <<EOF
NetGuardia development topology is ready.
Mgmt: http://<host-ip>:8080
external (10.10.1.{2-7}) -> router -> ng-ext
ng-ext <-> net-guardia XSK <-> ng-int
ng-int -> internal (10.10.2.{2-6})
EOF
}
main() {
parse_args "$@"
require_root
require_linux_host
check_host_tools
generate_compose_file
detect_runtime
preflight_cleanup
: >"$LOG_FILE"
info "Compose log: $LOG_FILE"
run_logged "Building containers" "${COMPOSE_CMD[@]}" build
generate_compose_file
run_logged "Starting containers" "${COMPOSE_CMD[@]}" up -d
info "Containers running"
"${RT_CMD[@]}" ps --format "table {{.Names}}\t{{.Status}}" 2>/dev/null || "${RT_CMD[@]}" ps
link_container_namespaces
create_topology
write_interface_mapping
connectivity_check
print_summary
}
main "$@"

134
deploy/scripts/install.sh Executable file
View File

@ -0,0 +1,134 @@
#!/bin/bash
# install.sh — Install NetGuardia on a fresh system.
#
# Usage:
# install.sh # Download from GitHub Release
# install.sh --local /path/to/binary # Use a pre-built local binary
#
set -euo pipefail
# ── Helpers ──────────────────────────────────────────────────────────────────
info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
fatal() { printf '\033[1;31m[FATAL]\033[0m %s\n' "$*" >&2; exit 1; }
# ── Defaults ─────────────────────────────────────────────────────────────────
LOCAL_BINARY=""
INSTALL_DIR="/opt/netguardia"
BIN_DIR="${INSTALL_DIR}/bin"
DATA_DIR="/var/lib/netguardia"
LOG_DIR="/var/log/netguardia"
SERVICE_USER="netguardia"
SERVICE_GROUP="netguardia"
GITHUB_REPO="dalaw2/NetGuardia"
# ── Parse arguments ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--local)
[[ -z "${2:-}" ]] && fatal "--local requires a path to the binary"
LOCAL_BINARY="$2"
shift 2
;;
-h|--help)
echo "Usage: $0 [--local /path/to/binary]"
exit 0
;;
*)
fatal "Unknown argument: $1"
;;
esac
done
# ── Validate local binary (if provided) ─────────────────────────────────────
if [[ -n "${LOCAL_BINARY}" ]]; then
[[ -f "${LOCAL_BINARY}" ]] || fatal "Local binary not found: ${LOCAL_BINARY}"
[[ -x "${LOCAL_BINARY}" ]] || fatal "Local binary is not executable: ${LOCAL_BINARY}"
info "Using local binary: ${LOCAL_BINARY}"
fi
# ── Must be root ─────────────────────────────────────────────────────────────
[[ "$(id -u)" -eq 0 ]] || fatal "This script must be run as root"
# ── Install runtime dependencies (SQLCipher needs OpenSSL) ──────────────────
if command -v apt-get &>/dev/null; then
info "Refreshing apt package metadata"
DEBIAN_FRONTEND=noninteractive apt-get update >/dev/null 2>&1 || warn "Could not refresh apt metadata"
info "Installing runtime dependencies (libssl)"
DEBIAN_FRONTEND=noninteractive apt-get install -y libssl3 >/dev/null 2>&1 || warn "Could not install libssl3"
elif command -v dnf &>/dev/null; then
info "Installing runtime dependencies (openssl-libs)"
dnf install -y openssl-libs >/dev/null 2>&1 || warn "Could not install openssl-libs"
fi
# ── Create system user ───────────────────────────────────────────────────────
if ! id "${SERVICE_USER}" &>/dev/null; then
info "Creating system user: ${SERVICE_USER}"
useradd --system --no-create-home --shell /usr/sbin/nologin "${SERVICE_USER}"
fi
# ── Create directories ───────────────────────────────────────────────────────
info "Creating directories"
mkdir -p "${BIN_DIR}" "${DATA_DIR}" "${LOG_DIR}"
chown "${SERVICE_USER}:${SERVICE_GROUP}" "${DATA_DIR}" "${LOG_DIR}"
# ── Obtain the binary ────────────────────────────────────────────────────────
if [[ -n "${LOCAL_BINARY}" ]]; then
# --local mode: skip download and checksum entirely
info "Installing local binary to ${BIN_DIR}/net-guardia"
install -m 0755 "${LOCAL_BINARY}" "${BIN_DIR}/net-guardia"
else
# Download from GitHub Release
info "Fetching latest release from GitHub (${GITHUB_REPO})"
LATEST_TAG=$(curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
[[ -n "${LATEST_TAG}" ]] || fatal "Could not determine latest release tag"
info "Latest release: ${LATEST_TAG}"
DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_TAG}/net-guardia-linux-amd64"
CHECKSUMS_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_TAG}/SHA256SUMS"
TMPDIR=$(mktemp -d)
trap 'rm -rf "${TMPDIR}"' EXIT
info "Downloading binary"
curl -fSL -o "${TMPDIR}/net-guardia" "${DOWNLOAD_URL}"
info "Downloading SHA256SUMS"
if ! curl -fSL -o "${TMPDIR}/SHA256SUMS" "${CHECKSUMS_URL}"; then
fatal "SHA256SUMS file not found in release — aborting"
fi
info "Verifying checksum"
(cd "${TMPDIR}" && sha256sum -c SHA256SUMS)
install -m 0755 "${TMPDIR}/net-guardia" "${BIN_DIR}/net-guardia"
fi
# ── Install systemd unit ─────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
if [[ -f "${DEPLOY_DIR}/netguardia.service" ]]; then
info "Installing systemd unit"
install -m 0644 "${DEPLOY_DIR}/netguardia.service" /etc/systemd/system/netguardia.service
systemctl daemon-reload
systemctl enable netguardia.service
else
warn "netguardia.service not found at ${DEPLOY_DIR}/netguardia.service — skipping"
fi
# ── Install logrotate config ─────────────────────────────────────────────────
if [[ -f "${DEPLOY_DIR}/logrotate.conf" ]]; then
info "Installing logrotate config"
install -m 0644 "${DEPLOY_DIR}/logrotate.conf" /etc/logrotate.d/netguardia
else
warn "logrotate.conf not found — skipping"
fi
# ── Done ─────────────────────────────────────────────────────────────────────
info "NetGuardia installed successfully"
info " Binary: ${BIN_DIR}/net-guardia"
info " Data: ${DATA_DIR}"
info " Logs: ${LOG_DIR}"
info " Service: systemctl start netguardia"

View File

@ -0,0 +1,127 @@
#!/bin/bash
# External network traffic generator
# Simulates external hosts sending traffic toward the internal network
INTERNAL_IP="10.10.2.2"
SELF_BASE="10.10.1"
echo "[external] Traffic generator started"
# Wait for network and routing to be ready
sleep 5
until ping -c 1 -W 1 $INTERNAL_IP &>/dev/null; do
echo "[external] Waiting for internal connectivity..."
sleep 2
done
echo "[external] Internal network reachable"
# --- Benign traffic functions ---
http_traffic() {
while true; do
for src in 2 3 4; do
local ip="${SELF_BASE}.${src}"
# Various HTTP methods
curl -s --interface $ip -o /dev/null -m 3 http://${INTERNAL_IP}/ 2>/dev/null
curl -s --interface $ip -o /dev/null -m 3 -X POST -d "data=test" http://${INTERNAL_IP}/ 2>/dev/null
curl -s --interface $ip -o /dev/null -m 3 -X HEAD http://${INTERNAL_IP}/ 2>/dev/null
curl -s --interface $ip -o /dev/null -m 3 -X OPTIONS http://${INTERNAL_IP}/ 2>/dev/null
done
sleep $((RANDOM % 3 + 1))
done
}
ssh_traffic() {
while true; do
for src in 2 5; do
local ip="${SELF_BASE}.${src}"
# SSH connection attempts (will fail but generates TCP SYN to port 22)
timeout 2 bash -c "echo | nc -w 1 -s $ip $INTERNAL_IP 22" 2>/dev/null || true
done
sleep $((RANDOM % 5 + 3))
done
}
dns_traffic() {
while true; do
for src in 2 3 6; do
local ip="${SELF_BASE}.${src}"
# UDP packets to port 53 (DNS-like)
echo -ne '\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x07example\x03com\x00\x00\x01\x00\x01' | \
nc -u -w 1 -s $ip $INTERNAL_IP 53 2>/dev/null || true
done
sleep $((RANDOM % 4 + 2))
done
}
udp_traffic() {
while true; do
for src in 2 4 7; do
local ip="${SELF_BASE}.${src}"
for port in 5000 5001 8000 9090; do
echo "udp-payload-$(date +%s)" | nc -u -w 1 -s $ip $INTERNAL_IP $port 2>/dev/null || true
done
done
sleep $((RANDOM % 3 + 2))
done
}
tcp_traffic() {
while true; do
for src in 3 5 6; do
local ip="${SELF_BASE}.${src}"
for port in 3000 4000 6379 5432; do
timeout 2 bash -c "echo 'hello' | nc -w 1 -s $ip $INTERNAL_IP $port" 2>/dev/null || true
done
done
sleep $((RANDOM % 4 + 2))
done
}
icmp_traffic() {
while true; do
for src in 2 3 4 5; do
local ip="${SELF_BASE}.${src}"
ping -c 2 -W 1 -I $ip $INTERNAL_IP >/dev/null 2>&1 || true
done
sleep $((RANDOM % 5 + 3))
done
}
# --- Attack-like traffic (low intensity, for ML training) ---
syn_scan() {
while true; do
sleep $((RANDOM % 30 + 30))
src="${SELF_BASE}.$((RANDOM % 3 + 5))"
echo "[external] SYN scan burst from $src"
# Quick port scan pattern
for port in 22 80 443 8080 3306 5432 6379 8443 9090 27017; do
timeout 1 bash -c "echo | nc -w 1 -s $src $INTERNAL_IP $port" 2>/dev/null || true
done
done
}
udp_burst() {
while true; do
sleep $((RANDOM % 60 + 45))
src="${SELF_BASE}.$((RANDOM % 3 + 5))"
echo "[external] UDP burst from $src"
for i in $(seq 1 50); do
echo "flood-$i" | nc -u -w 0 -s $src $INTERNAL_IP $((RANDOM % 10000 + 1024)) 2>/dev/null || true
done
done
}
# Launch all traffic generators in background
http_traffic &
ssh_traffic &
dns_traffic &
udp_traffic &
tcp_traffic &
icmp_traffic &
syn_scan &
udp_burst &
echo "[external] All traffic generators running"
wait

View File

@ -0,0 +1,90 @@
#!/bin/bash
# Internal network traffic generator
# Simulates internal hosts sending traffic toward the external network
# Also runs services (HTTP, SSH) for external to connect to
EXTERNAL_IP="10.10.1.2"
SELF_BASE="10.10.2"
echo "[internal] Traffic generator started"
# Start HTTP server on all interfaces
httpd -D FOREGROUND &
HTTPD_PID=$!
# Start a simple SSH listener (for connection pattern generation)
ssh-keygen -A 2>/dev/null
/usr/sbin/sshd 2>/dev/null || true
# Wait for network to be ready
sleep 3
until ping -c 1 -W 1 $EXTERNAL_IP &>/dev/null; do
echo "[internal] Waiting for external connectivity..."
sleep 2
done
echo "[internal] External network reachable"
# --- Benign outbound traffic ---
http_outbound() {
while true; do
for src in 2 3 4; do
local ip="${SELF_BASE}.${src}"
curl -s --interface $ip -o /dev/null -m 3 http://${EXTERNAL_IP}/ 2>/dev/null
curl -s --interface $ip -o /dev/null -m 3 -X POST -d "query=test" http://${EXTERNAL_IP}/ 2>/dev/null
done
sleep $((RANDOM % 4 + 2))
done
}
dns_outbound() {
while true; do
for src in 2 5; do
local ip="${SELF_BASE}.${src}"
echo -ne '\x00\x02\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x06google\x03com\x00\x00\x01\x00\x01' | \
nc -u -w 1 -s $ip $EXTERNAL_IP 53 2>/dev/null || true
done
sleep $((RANDOM % 5 + 3))
done
}
udp_outbound() {
while true; do
for src in 3 4 6; do
local ip="${SELF_BASE}.${src}"
echo "internal-udp-$(date +%s)" | nc -u -w 1 -s $ip $EXTERNAL_IP $((RANDOM % 5000 + 5000)) 2>/dev/null || true
done
sleep $((RANDOM % 4 + 2))
done
}
tcp_outbound() {
while true; do
for src in 2 5 6; do
local ip="${SELF_BASE}.${src}"
timeout 2 bash -c "echo 'ping' | nc -w 1 -s $ip $EXTERNAL_IP $((RANDOM % 1000 + 3000))" 2>/dev/null || true
done
sleep $((RANDOM % 5 + 3))
done
}
icmp_outbound() {
while true; do
for src in 2 3; do
local ip="${SELF_BASE}.${src}"
ping -c 1 -W 1 -I $ip $EXTERNAL_IP >/dev/null 2>&1 || true
done
sleep $((RANDOM % 6 + 4))
done
}
# Launch all traffic generators
http_outbound &
dns_outbound &
udp_outbound &
tcp_outbound &
icmp_outbound &
echo "[internal] All traffic generators + services running"
echo "[internal] Services: HTTP(:80), SSH(:22)"
wait

View File

@ -0,0 +1,5 @@
[build]
target = ["bpfeb-unknown-none", "bpfel-unknown-none"]
[unstable]
build-std = ["core"]

View File

@ -1,14 +1,12 @@
[package]
name = "net-guardia-egress-ebpf"
version = "0.1.0"
name = "egress-ebpf"
version = "1.0.0"
edition = "2024"
[dependencies]
net-guardia-common = { path = "../net-guardia-common" }
net-guardia-abi = { workspace = true, features = ["kernel"] }
aya-ebpf = { workspace = true }
aya-log-ebpf = { workspace = true }
network-types = "0.0.7"
[build-dependencies]
which = { workspace = true }
@ -16,3 +14,6 @@ which = { workspace = true }
[[bin]]
name = "net-guardia-egress"
path = "src/main.rs"
test = false
doctest = false
bench = false

5
egress-ebpf/build.rs Normal file
View File

@ -0,0 +1,5 @@
fn main() {
if let Ok(linker) = which::which("bpf-linker") {
println!("cargo:rerun-if-changed={}", linker.display());
}
}

1
egress-ebpf/src/lib.rs Normal file
View File

@ -0,0 +1 @@
#![no_std]

43
egress-ebpf/src/main.rs Normal file
View File

@ -0,0 +1,43 @@
#![cfg_attr(any(target_arch = "bpf", target_os = "none"), no_std)]
#![no_main]
use aya_ebpf::bindings::xdp_action;
use aya_ebpf::macros::{map, xdp};
use aya_ebpf::maps::{Array, XskMap};
use aya_ebpf::programs::XdpContext;
#[allow(unused_imports)]
use aya_log_ebpf::info;
use net_guardia_abi::ebpf::parsing;
use net_guardia_abi::ebpf::symmetric_hash::symmetric_queue_id;
use net_guardia_abi::model::parsed_packet::ParsedPacket;
#[map]
static NUM_QUEUES: Array<u32> = Array::with_max_entries(1, 0);
#[map]
static EGRESS_XSKS_MAP: XskMap = XskMap::pinned(64, 0);
#[xdp]
pub fn net_guardia(ctx: XdpContext) -> u32 {
let queue_id = unsafe { compute_symmetric_queue_id(&ctx).unwrap_or((*ctx.ctx).rx_queue_index) };
match EGRESS_XSKS_MAP.redirect(queue_id, 0) {
Ok(action) => action,
Err(_) => xdp_action::XDP_PASS,
}
}
#[inline(always)]
unsafe fn compute_symmetric_queue_id(ctx: &XdpContext) -> Option<u32> {
unsafe {
let mut pkt = core::mem::zeroed::<ParsedPacket>();
parsing::parse_packet(ctx.data(), ctx.data_end(), &mut pkt)?;
let num_q = *NUM_QUEUES.get(0)?;
symmetric_queue_id(&pkt, num_q)
}
}
#[cfg(all(not(test), any(target_arch = "bpf", target_os = "none")))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

View File

@ -0,0 +1,5 @@
[build]
target = ["bpfeb-unknown-none", "bpfel-unknown-none"]
[unstable]
build-std = ["core"]

View File

@ -1,14 +1,13 @@
[package]
name = "net-guardia-ingress-ebpf"
version = "0.1.0"
name = "ingress-ebpf"
version = "1.0.0"
edition = "2024"
[dependencies]
net-guardia-common = { path = "../net-guardia-common" }
net-guardia-abi = { workspace = true, features = ["kernel"] }
aya-ebpf = { workspace = true }
aya-log-ebpf = { workspace = true }
network-types = "0.0.7"
network-types = { workspace = true }
[build-dependencies]
which = { workspace = true }
@ -16,3 +15,6 @@ which = { workspace = true }
[[bin]]
name = "net-guardia-ingress"
path = "src/main.rs"
test = false
doctest = false
bench = false

5
ingress-ebpf/build.rs Normal file
View File

@ -0,0 +1,5 @@
fn main() {
if let Ok(linker) = which::which("bpf-linker") {
println!("cargo:rerun-if-changed={}", linker.display());
}
}

View File

@ -0,0 +1,113 @@
use aya_ebpf::macros::map;
use aya_ebpf::maps::HashMap;
use aya_ebpf::maps::LpmTrie;
use aya_ebpf::maps::lpm_trie::Key;
use net_guardia_abi::define::setting::{MAX_GEO_ENTRIES, MAX_RULES};
use net_guardia_abi::model::ip_address::{IPv4, IPv6};
use net_guardia_abi::model::parsed_packet::ParsedPacket;
use net_guardia_abi::model::port_rule::PortRule;
#[map]
static IPV4_SRC_WHITELIST: HashMap<IPv4, PortRule> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV6_SRC_WHITELIST: HashMap<IPv6, PortRule> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV4_DST_WHITELIST: HashMap<IPv4, PortRule> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV6_DST_WHITELIST: HashMap<IPv6, PortRule> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV4_SRC_BLACKLIST: HashMap<IPv4, PortRule> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV6_SRC_BLACKLIST: HashMap<IPv6, PortRule> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV4_DST_BLACKLIST: HashMap<IPv4, PortRule> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV6_DST_BLACKLIST: HashMap<IPv6, PortRule> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static GEO_BLOCK_V4: LpmTrie<u32, u8> = LpmTrie::with_max_entries(MAX_GEO_ENTRIES, 0);
#[map]
static GEO_BLOCK_V6: LpmTrie<u128, u8> = LpmTrie::with_max_entries(MAX_GEO_ENTRIES, 0);
pub fn ipv4_is_geo_blocked(pkt: &ParsedPacket) -> bool {
let src_ip = u32::from_ne_bytes([pkt.src_ip[0], pkt.src_ip[1], pkt.src_ip[2], pkt.src_ip[3]]);
let key = Key::new(32, src_ip);
GEO_BLOCK_V4.get(&key).is_some()
}
pub fn ipv6_is_geo_blocked(pkt: &ParsedPacket) -> bool {
let src_ip = u128::from_ne_bytes(pkt.src_ip);
let key = Key::new(128, src_ip);
GEO_BLOCK_V6.get(&key).is_some()
}
pub fn ipv4_is_whitelisted(pkt: &ParsedPacket) -> bool {
let src_ip = pkt.src_ip_v4();
let dst_ip = pkt.dst_ip_v4();
unsafe {
if let Some(rule) = IPV4_SRC_WHITELIST.get(&src_ip)
&& rule.contains(pkt.src_port)
{
return true;
}
if let Some(rule) = IPV4_DST_WHITELIST.get(&dst_ip)
&& rule.contains(pkt.dst_port)
{
return true;
}
}
false
}
pub fn ipv6_is_whitelisted(pkt: &ParsedPacket) -> bool {
let src_ip = pkt.src_ip_v6();
let dst_ip = pkt.dst_ip_v6();
unsafe {
if let Some(rule) = IPV6_SRC_WHITELIST.get(&src_ip)
&& rule.contains(pkt.src_port)
{
return true;
}
if let Some(rule) = IPV6_DST_WHITELIST.get(&dst_ip)
&& rule.contains(pkt.dst_port)
{
return true;
}
}
false
}
pub fn ipv4_is_blacklisted(pkt: &ParsedPacket) -> bool {
let src_ip = pkt.src_ip_v4();
let dst_ip = pkt.dst_ip_v4();
unsafe {
if let Some(rule) = IPV4_SRC_BLACKLIST.get(&src_ip)
&& rule.contains(pkt.src_port)
{
return true;
}
if let Some(rule) = IPV4_DST_BLACKLIST.get(&dst_ip)
&& rule.contains(pkt.dst_port)
{
return true;
}
}
false
}
pub fn ipv6_is_blacklisted(pkt: &ParsedPacket) -> bool {
let src_ip = pkt.src_ip_v6();
let dst_ip = pkt.dst_ip_v6();
unsafe {
if let Some(rule) = IPV6_SRC_BLACKLIST.get(&src_ip)
&& rule.contains(pkt.src_port)
{
return true;
}
if let Some(rule) = IPV6_DST_BLACKLIST.get(&dst_ip)
&& rule.contains(pkt.dst_port)
{
return true;
}
}
false
}

View File

@ -0,0 +1,3 @@
pub mod access_control;
pub mod protocol_filter;
pub mod rate_limit;

View File

@ -0,0 +1,140 @@
use core::slice;
use aya_ebpf::macros::map;
use aya_ebpf::maps::{Array, HashMap};
use net_guardia_abi::define::setting::MAX_RULES;
use net_guardia_abi::define::tcp_flags::*;
use net_guardia_abi::model::empty::EmptyMapValue;
use net_guardia_abi::model::http_method::HttpMethodBitmap;
use net_guardia_abi::model::ip_address::*;
use net_guardia_abi::model::parsed_packet::ParsedPacket;
use network_types::ip::IpProto;
#[map]
static IPV4_HTTP_SERVICE: HashMap<AddrPortV4, HttpMethodBitmap> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV6_HTTP_SERVICE: HashMap<AddrPortV6, HttpMethodBitmap> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static SSH_WHITE_LIST_ENABLE: Array<EmptyMapValue> = Array::with_max_entries(1, 0);
#[map]
static IPV4_SSH_SERVICE: HashMap<AddrPortV4, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV6_SSH_SERVICE: HashMap<AddrPortV6, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV4_SSH_WHITE_LIST: HashMap<IPv4, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV6_SSH_WHITE_LIST: HashMap<IPv6, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV4_SSH_BLACK_LIST: HashMap<IPv4, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
#[map]
static IPV6_SSH_BLACK_LIST: HashMap<IPv6, EmptyMapValue> = HashMap::with_max_entries(MAX_RULES as u32, 0);
pub fn ipv4_service_rule_violation(start: usize, end: usize, pkt: &ParsedPacket) -> bool {
let source = pkt.src_addr_v4();
let destination = pkt.dst_addr_v4();
http_service_violation(start, end, pkt, &IPV4_HTTP_SERVICE, &destination)
|| ipv4_ssh_service_violation(&source, &destination)
}
pub fn ipv6_service_rule_violation(start: usize, end: usize, pkt: &ParsedPacket) -> bool {
let source = pkt.src_addr_v6();
let destination = pkt.dst_addr_v6();
http_service_violation(start, end, pkt, &IPV6_HTTP_SERVICE, &destination)
|| ipv6_ssh_service_violation(&source, &destination)
}
#[inline(always)]
fn http_service_violation<K>(
start: usize,
end: usize,
pkt: &ParsedPacket,
map: &HashMap<K, HttpMethodBitmap>,
destination: &K,
) -> bool {
match map.get_ptr_mut(destination) {
Some(allow_method) => {
if pkt.protocol != IpProto::Tcp as u8 {
return false;
}
if pkt.tcp_flags & (TCP_SYN | TCP_RST | TCP_FIN) != 0 {
return false;
}
if pkt.tcp_flags & (TCP_PSH | TCP_ACK) != (TCP_PSH | TCP_ACK) {
return false;
}
if start + 15 > end {
return false;
}
let l4_offset = match pkt.ip_version {
value if value == IpVersion::V4.as_u8() => {
14 + ((unsafe { *((start + 14) as *const u8) } & 0x0F) as usize) * 4
}
value if value == IpVersion::V6.as_u8() => 14 + 40,
_ => return false,
};
if start + l4_offset + 13 > end {
return false;
}
let doff = (unsafe { *((start + l4_offset + 12) as *const u8) } >> 4) as usize;
if !(5..=15).contains(&doff) {
return false;
}
let payload_offset = l4_offset + doff * 4;
match get_http_request_method(start, end, payload_offset) {
Some(http_method) => unsafe { *allow_method & http_method == 0 },
None => false,
}
}
None => false,
}
}
#[inline(always)]
fn get_http_request_method(start: usize, end: usize, offset: usize) -> Option<HttpMethodBitmap> {
if start + offset + 8 > end {
return None;
}
let data = unsafe { slice::from_raw_parts((start + offset) as *const u8, 8) };
match &data[..4] {
b"GET " => Some(1 << 0),
b"POST" if &data[4..5] == b" " => Some(1 << 1),
b"PUT " => Some(1 << 2),
b"DELE" if &data[4..7] == b"TE " => Some(1 << 3),
b"HEAD" if &data[4..5] == b" " => Some(1 << 4),
b"OPTI" if &data[4..8] == b"ONS " => Some(1 << 5),
b"PATC" if &data[4..6] == b"H " => Some(1 << 6),
b"TRAC" if &data[4..6] == b"E " => Some(1 << 7),
b"CONN" if &data[4..8] == b"ECT " => Some(1 << 8),
_ => None,
}
}
#[inline(always)]
fn ipv4_ssh_service_violation(source: &AddrPortV4, destination: &AddrPortV4) -> bool {
unsafe {
if IPV4_SSH_SERVICE.get(destination).is_some() {
if matches!(SSH_WHITE_LIST_ENABLE.get(0), Some(&v) if v != 0) {
IPV4_SSH_WHITE_LIST.get(&source.ip()).is_none()
} else {
IPV4_SSH_BLACK_LIST.get(&source.ip()).is_some()
}
} else {
false
}
}
}
#[inline(always)]
fn ipv6_ssh_service_violation(source: &AddrPortV6, destination: &AddrPortV6) -> bool {
unsafe {
if IPV6_SSH_SERVICE.get(destination).is_some() {
if matches!(SSH_WHITE_LIST_ENABLE.get(0), Some(&v) if v != 0) {
IPV6_SSH_WHITE_LIST.get(&source.ip()).is_none()
} else {
IPV6_SSH_BLACK_LIST.get(&source.ip()).is_some()
}
} else {
false
}
}
}

View File

@ -0,0 +1,188 @@
use aya_ebpf::helpers::bpf_ktime_get_ns;
use aya_ebpf::macros::map;
use aya_ebpf::maps::{Array, LruHashMap};
use net_guardia_abi::define::drop_reason::*;
use net_guardia_abi::define::rate_limit::*;
use net_guardia_abi::define::setting::*;
use net_guardia_abi::define::tcp_flags::*;
use net_guardia_abi::model::ip_address::{IPv4, IPv6, IpVersion};
use net_guardia_abi::model::parsed_packet::ParsedPacket;
use net_guardia_abi::model::rate_limit::RateState;
use network_types::ip::IpProto;
#[map]
static RATE_LIMIT_CONFIG: Array<u64> = Array::with_max_entries(5, 0);
#[map]
static IPV4_PACKET_RATE_MAP: LruHashMap<IPv4, RateState> = LruHashMap::with_max_entries(MAX_TRACKED_IPS, 0);
#[map]
static IPV6_PACKET_RATE_MAP: LruHashMap<IPv6, RateState> = LruHashMap::with_max_entries(MAX_TRACKED_IPS, 0);
#[map]
static IPV4_SYN_RATE_MAP: LruHashMap<IPv4, RateState> = LruHashMap::with_max_entries(MAX_TRACKED_IPS, 0);
#[map]
static IPV6_SYN_RATE_MAP: LruHashMap<IPv6, RateState> = LruHashMap::with_max_entries(MAX_TRACKED_IPS, 0);
#[map]
static IPV4_UDP_RATE_MAP: LruHashMap<IPv4, RateState> = LruHashMap::with_max_entries(MAX_TRACKED_IPS, 0);
#[map]
static IPV6_UDP_RATE_MAP: LruHashMap<IPv6, RateState> = LruHashMap::with_max_entries(MAX_TRACKED_IPS, 0);
#[map]
static IPV4_DNS_RATE_MAP: LruHashMap<IPv4, RateState> = LruHashMap::with_max_entries(MAX_TRACKED_IPS, 0);
#[map]
static IPV6_DNS_RATE_MAP: LruHashMap<IPv6, RateState> = LruHashMap::with_max_entries(MAX_TRACKED_IPS, 0);
pub fn should_drop(pkt: &ParsedPacket) -> Option<u8> {
match pkt.ip_version {
value if value == IpVersion::V4.as_u8() => ipv4_should_drop(pkt),
value if value == IpVersion::V6.as_u8() => ipv6_should_drop(pkt),
_ => None,
}
}
#[inline(always)]
fn get_config(index: u32, default: u64) -> u64 {
RATE_LIMIT_CONFIG
.get(index)
.copied()
.filter(|&v| v > 0)
.unwrap_or(default)
}
#[inline(always)]
fn is_syn_only(pkt: &ParsedPacket) -> bool {
pkt.protocol == IpProto::Tcp as u8 && (pkt.tcp_flags & TCP_SYN != 0) && (pkt.tcp_flags & TCP_ACK == 0)
}
#[inline(always)]
fn check_rate<K>(map: &LruHashMap<K, RateState>, key: &K, now: u64, window: u64, limit: u64) -> bool {
unsafe {
if let Some(state) = map.get_ptr_mut(key) {
if now - (*state).window_start >= window {
(*state).count = 1;
(*state).window_start = now;
} else {
(*state).count += 1;
if (*state).count > limit {
return true;
}
}
} else {
let new_state = RateState {
count: 1,
window_start: now,
};
let _ = map.insert(key, &new_state, 0);
}
}
false
}
#[inline(always)]
fn ipv4_should_drop(pkt: &ParsedPacket) -> Option<u8> {
let now = unsafe { bpf_ktime_get_ns() };
let window = get_config(CFG_WINDOW_NS, DEFAULT_WINDOW_NS);
let src_ip = pkt.src_ip_v4();
if check_rate(
&IPV4_PACKET_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_PACKET_RATE, DEFAULT_PACKET_RATE),
) {
return Some(DROP_REASON_RATE_LIMIT_PKT);
}
if is_syn_only(pkt)
&& check_rate(
&IPV4_SYN_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE),
)
{
return Some(DROP_REASON_RATE_LIMIT_SYN);
}
if pkt.protocol == IpProto::Udp as u8
&& check_rate(
&IPV4_UDP_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE),
)
{
return Some(DROP_REASON_RATE_LIMIT_UDP);
}
if pkt.protocol == IpProto::Udp as u8
&& pkt.dst_port == 53
&& check_rate(
&IPV4_DNS_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE),
)
{
return Some(DROP_REASON_RATE_LIMIT_DNS);
}
None
}
#[inline(always)]
fn ipv6_should_drop(pkt: &ParsedPacket) -> Option<u8> {
let now = unsafe { bpf_ktime_get_ns() };
let window = get_config(CFG_WINDOW_NS, DEFAULT_WINDOW_NS);
let src_ip = pkt.src_ip_v6();
if check_rate(
&IPV6_PACKET_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_PACKET_RATE, DEFAULT_PACKET_RATE),
) {
return Some(DROP_REASON_RATE_LIMIT_PKT);
}
if is_syn_only(pkt)
&& check_rate(
&IPV6_SYN_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE),
)
{
return Some(DROP_REASON_RATE_LIMIT_SYN);
}
if pkt.protocol == IpProto::Udp as u8
&& check_rate(
&IPV6_UDP_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE),
)
{
return Some(DROP_REASON_RATE_LIMIT_UDP);
}
if pkt.protocol == IpProto::Udp as u8
&& pkt.dst_port == 53
&& check_rate(
&IPV6_DNS_RATE_MAP,
&src_ip,
now,
window,
get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE),
)
{
return Some(DROP_REASON_RATE_LIMIT_DNS);
}
None
}

1
ingress-ebpf/src/lib.rs Normal file
View File

@ -0,0 +1 @@
#![no_std]

232
ingress-ebpf/src/main.rs Normal file
View File

@ -0,0 +1,232 @@
#![cfg_attr(any(target_arch = "bpf", target_os = "none"), no_std)]
#![no_main]
mod action;
use aya_ebpf::bindings::xdp_action;
use aya_ebpf::helpers::bpf_ktime_get_ns;
use aya_ebpf::macros::{map, xdp};
use aya_ebpf::maps::{Array, PerCpuArray, ProgramArray, RingBuf, XskMap};
use aya_ebpf::programs::XdpContext;
#[allow(unused_imports)]
use aya_log_ebpf::info;
use net_guardia_abi::define::drop_reason::*;
use net_guardia_abi::define::pipeline::*;
use net_guardia_abi::ebpf::parsing;
use net_guardia_abi::ebpf::symmetric_hash::symmetric_queue_id;
use net_guardia_abi::model::drop_event::DropEvent;
use net_guardia_abi::model::ip_address::IpVersion;
use net_guardia_abi::model::parsed_packet::ParsedPacket;
use crate::action::{access_control, protocol_filter, rate_limit};
#[map]
static PROGRAM_ARRAY: ProgramArray = ProgramArray::with_max_entries(MAX_STAGES, 0);
#[map]
static NEXT_STAGE: Array<u32> = Array::with_max_entries(MAX_STAGES, 0);
#[map]
static PARSED_PACKET: PerCpuArray<ParsedPacket> = PerCpuArray::with_max_entries(1, 0);
#[map]
static INGRESS_XSKS_MAP: XskMap = XskMap::pinned(64, 0);
#[map]
static NUM_QUEUES: Array<u32> = Array::with_max_entries(1, 0);
#[map]
static DROP_EVENTS: RingBuf = RingBuf::with_byte_size(256 * 1024, 0);
#[xdp]
pub fn net_guardia(ctx: XdpContext) -> u32 {
unsafe {
packet_intake(&ctx);
let _ = PROGRAM_ARRAY.tail_call(&ctx, STAGE_TRANSMISSION);
xdp_action::XDP_PASS
}
}
#[inline(always)]
fn chain_next(ctx: &XdpContext, current_id: u32) {
unsafe {
if let Some(&next_slot) = NEXT_STAGE.get(current_id)
&& next_slot != STAGE_NONE
{
let _ = PROGRAM_ARRAY.tail_call(ctx, next_slot);
}
let _ = PROGRAM_ARRAY.tail_call(ctx, STAGE_TRANSMISSION);
}
}
#[inline(always)]
fn emit_drop_event(pkt: &ParsedPacket, reason: u8) {
if let Some(mut entry) = DROP_EVENTS.reserve::<DropEvent>(0) {
unsafe {
let event = &mut *entry.as_mut_ptr();
event.timestamp_ns = bpf_ktime_get_ns();
event.src_ip = pkt.src_ip;
event.dst_ip = pkt.dst_ip;
event.src_port = pkt.src_port;
event.dst_port = pkt.dst_port;
event.protocol = pkt.protocol;
event.reason = reason;
event.ip_version = pkt.ip_version;
event._pad = 0;
}
entry.submit(0);
}
}
#[inline(always)]
unsafe fn packet_intake(ctx: &XdpContext) {
let Some(ptr) = PARSED_PACKET.get_ptr_mut(0) else {
return;
};
if unsafe { parsing::parse_packet(ctx.data(), ctx.data_end(), ptr).is_some() } {
unsafe {
(*ptr).timestamp_ns = bpf_ktime_get_ns();
}
chain_next(ctx, STAGE_ENTRY);
}
}
#[xdp]
pub fn access_control(ctx: XdpContext) -> u32 {
unsafe {
match try_access_control(&ctx) {
Ok(action) => action,
Err(_) => {
chain_next(&ctx, STAGE_ACCESS_CONTROL);
xdp_action::XDP_PASS
}
}
}
}
#[inline(always)]
unsafe fn try_access_control(ctx: &XdpContext) -> Result<u32, ()> {
unsafe {
let ptr = PARSED_PACKET.get_ptr(0).ok_or(())?;
let pkt = &*ptr;
match pkt.ip_version {
value if value == IpVersion::V4.as_u8() => {
if access_control::ipv4_is_whitelisted(pkt) {
let _ = PROGRAM_ARRAY.tail_call(ctx, STAGE_TRANSMISSION);
return Err(());
}
if access_control::ipv4_is_geo_blocked(pkt) {
emit_drop_event(pkt, DROP_REASON_GEO_BLOCK);
return Ok(xdp_action::XDP_DROP);
}
if access_control::ipv4_is_blacklisted(pkt) {
emit_drop_event(pkt, DROP_REASON_ACL_BLACKLIST);
return Ok(xdp_action::XDP_DROP);
}
}
value if value == IpVersion::V6.as_u8() => {
if access_control::ipv6_is_whitelisted(pkt) {
let _ = PROGRAM_ARRAY.tail_call(ctx, STAGE_TRANSMISSION);
return Err(());
}
if access_control::ipv6_is_geo_blocked(pkt) {
emit_drop_event(pkt, DROP_REASON_GEO_BLOCK);
return Ok(xdp_action::XDP_DROP);
}
if access_control::ipv6_is_blacklisted(pkt) {
emit_drop_event(pkt, DROP_REASON_ACL_BLACKLIST);
return Ok(xdp_action::XDP_DROP);
}
}
_ => {}
}
chain_next(ctx, STAGE_ACCESS_CONTROL);
Err(())
}
}
#[xdp]
pub fn rate_limit(ctx: XdpContext) -> u32 {
unsafe {
match try_rate_limit(&ctx) {
Ok(action) => action,
Err(_) => {
chain_next(&ctx, STAGE_RATE_LIMIT);
xdp_action::XDP_PASS
}
}
}
}
#[inline(always)]
unsafe fn try_rate_limit(ctx: &XdpContext) -> Result<u32, ()> {
unsafe {
let ptr = PARSED_PACKET.get_ptr(0).ok_or(())?;
let pkt = &*ptr;
if let Some(reason) = rate_limit::should_drop(pkt) {
emit_drop_event(pkt, reason);
return Ok(xdp_action::XDP_DROP);
}
chain_next(ctx, STAGE_RATE_LIMIT);
Err(())
}
}
#[xdp]
pub fn protocol_filter(ctx: XdpContext) -> u32 {
unsafe {
match try_protocol_filter(&ctx) {
Ok(action) => action,
Err(_) => {
chain_next(&ctx, STAGE_SERVICE);
xdp_action::XDP_PASS
}
}
}
}
#[inline(always)]
unsafe fn try_protocol_filter(ctx: &XdpContext) -> Result<u32, ()> {
unsafe {
let start = ctx.data();
let end = ctx.data_end();
let ptr = PARSED_PACKET.get_ptr(0).ok_or(())?;
let pkt = &*ptr;
match pkt.ip_version {
value if value == IpVersion::V4.as_u8() => {
if protocol_filter::ipv4_service_rule_violation(start, end, pkt) {
emit_drop_event(pkt, DROP_REASON_PROTOCOL_FILTER);
return Ok(xdp_action::XDP_DROP);
}
}
value
if value == IpVersion::V6.as_u8() && protocol_filter::ipv6_service_rule_violation(start, end, pkt) =>
{
emit_drop_event(pkt, DROP_REASON_PROTOCOL_FILTER);
return Ok(xdp_action::XDP_DROP);
}
_ => {}
}
chain_next(ctx, STAGE_SERVICE);
Err(())
}
}
#[inline(always)]
unsafe fn compute_symmetric_queue_id() -> Option<u32> {
unsafe {
let pkt = &*PARSED_PACKET.get_ptr(0)?;
let num_q = *NUM_QUEUES.get(0)?;
symmetric_queue_id(pkt, num_q)
}
}
#[xdp]
pub fn transmission(ctx: XdpContext) -> u32 {
let queue_id = unsafe { compute_symmetric_queue_id().unwrap_or((*ctx.ctx).rx_queue_index) };
match INGRESS_XSKS_MAP.redirect(queue_id, 0) {
Ok(action) => action,
Err(_) => xdp_action::XDP_PASS,
}
}
#[cfg(all(not(test), any(target_arch = "bpf", target_os = "none")))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}

12
macros/Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "macros"
version = "1.0.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = { workspace = true }
quote = { workspace = true }
syn = { workspace = true }

591
macros/src/config.rs Normal file
View File

@ -0,0 +1,591 @@
use std::collections::BTreeMap;
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use syn::parse::{Parse, ParseStream};
use syn::spanned::Spanned;
use syn::{Error, Fields, Ident, ItemStruct, LitBool, LitStr, Result, Token, Type};
struct StructAttr {
default_section: Option<String>,
}
impl Parse for StructAttr {
fn parse(input: ParseStream) -> Result<Self> {
let mut section = None;
while !input.is_empty() {
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
let val: LitStr = input.parse()?;
if key == "section" {
section = Some(val.value());
}
if !input.is_empty() {
input.parse::<Token![,]>()?;
}
}
Ok(Self {
default_section: section,
})
}
}
enum ConfigField {
Setting(SettingField),
Flatten(FlattenField),
MappedParent(MappedParent),
}
struct SettingField {
ident: Ident,
ty: Type,
key: String,
default: String,
default_debug: Option<String>,
section: Option<String>,
api: bool,
}
struct FlattenField {
ident: Ident,
ty: Type,
}
struct MappedSetting {
key: String,
default: String,
default_debug: Option<String>,
parent: String,
sub_field: String,
section: Option<String>,
api: bool,
}
struct MappedParent {
ident: Ident,
ty: Type,
settings: Vec<MappedSetting>,
}
fn parse_struct_mapped_settings(
input: &mut ItemStruct,
default_section: &Option<String>,
) -> Result<Vec<MappedSetting>> {
let mut mapped = Vec::new();
let mut retained = Vec::new();
for attr in input.attrs.drain(..) {
if !attr.path().is_ident("setting") {
retained.push(attr);
continue;
}
let mut key = None;
let mut default = None;
let mut default_debug = None;
let mut path = None;
let mut section = None;
let mut api = true;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("key") {
let val: LitStr = meta.value()?.parse()?;
key = Some(val.value());
} else if meta.path.is_ident("default") {
let val: LitStr = meta.value()?.parse()?;
default = Some(val.value());
} else if meta.path.is_ident("default_debug") {
let val: LitStr = meta.value()?.parse()?;
default_debug = Some(val.value());
} else if meta.path.is_ident("path") {
let val: LitStr = meta.value()?.parse()?;
path = Some(val.value());
} else if meta.path.is_ident("section") {
let val: LitStr = meta.value()?.parse()?;
section = Some(val.value());
} else if meta.path.is_ident("api") {
let val: LitBool = meta.value()?.parse()?;
api = val.value();
}
Ok(())
})?;
if let (Some(key), Some(default), Some(path)) = (key, default, path) {
let (parent, sub_field) = path
.split_once('.')
.ok_or_else(|| Error::new(attr.span(), "#[setting] `path` must be `parent.sub_field`"))?;
mapped.push(MappedSetting {
key,
default,
default_debug,
parent: parent.to_string(),
sub_field: sub_field.to_string(),
section: section.or_else(|| default_section.clone()),
api,
});
} else {
retained.push(attr);
}
}
input.attrs = retained;
Ok(mapped)
}
fn parse_field(field: &mut syn::Field, default_section: &Option<String>) -> Result<Option<ConfigField>> {
let Some(idx) = field.attrs.iter().position(|a| a.path().is_ident("setting")) else {
return Ok(None);
};
let attr = field.attrs.remove(idx);
let mut is_flatten = false;
let mut key = None;
let mut default = None;
let mut default_debug = None;
let mut section = None;
let mut api = true;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("flatten") {
is_flatten = true;
} else if meta.path.is_ident("key") {
let val: LitStr = meta.value()?.parse()?;
key = Some(val.value());
} else if meta.path.is_ident("default") {
let val: LitStr = meta.value()?.parse()?;
default = Some(val.value());
} else if meta.path.is_ident("default_debug") {
let val: LitStr = meta.value()?.parse()?;
default_debug = Some(val.value());
} else if meta.path.is_ident("section") {
let val: LitStr = meta.value()?.parse()?;
section = Some(val.value());
} else if meta.path.is_ident("api") {
let val: LitBool = meta.value()?.parse()?;
api = val.value();
}
Ok(())
})?;
let ident = field
.ident
.clone()
.ok_or_else(|| Error::new(field.span(), "#[setting] only supports named fields"))?;
let ty = field.ty.clone();
if is_flatten {
return Ok(Some(ConfigField::Flatten(FlattenField { ident, ty })));
}
let key = key.ok_or_else(|| Error::new(attr.span(), "#[setting] requires `key`"))?;
let default = default.ok_or_else(|| Error::new(attr.span(), "#[setting] requires `default`"))?;
Ok(Some(ConfigField::Setting(SettingField {
ident,
ty,
key,
default,
default_debug,
section: section.or_else(|| default_section.clone()),
api,
})))
}
fn is_type(ty: &Type, name: &str) -> bool {
matches!(ty, Type::Path(tp) if tp.path.is_ident(name))
}
fn is_vec_string(ty: &Type) -> bool {
if let Type::Path(tp) = ty
&& let Some(seg) = tp.path.segments.last()
{
return seg.ident == "Vec";
}
false
}
fn parse_default_tokens(ty: &Type, default: &str) -> Result<TokenStream2> {
default.parse::<TokenStream2>().map_err(|err| {
Error::new(
ty.span(),
format!("invalid #[setting] default literal `{default}`: {err}"),
)
})
}
fn make_default_val(ty: &Type, default: &str) -> Result<TokenStream2> {
if is_type(ty, "String") {
Ok(quote! { #default.to_string() })
} else if is_type(ty, "bool") {
let val = default == "true" || default == "1";
Ok(quote! { #val })
} else if is_vec_string(ty) {
if default.is_empty() {
Ok(quote! { Vec::new() })
} else {
let items: Vec<&str> = default.split(',').map(|v| v.trim()).collect();
Ok(quote! { vec![#(#items.to_string()),*] })
}
} else {
let value = parse_default_tokens(ty, default)?;
Ok(quote! { #value })
}
}
fn gen_default(f: &SettingField) -> Result<TokenStream2> {
let ident = &f.ident;
let ty = &f.ty;
match &f.default_debug {
Some(dbg) => {
let release_val = make_default_val(ty, &f.default)?;
let debug_val = make_default_val(ty, dbg)?;
Ok(quote! { #ident: if cfg!(debug_assertions) { #debug_val } else { #release_val } })
}
None => {
let val = make_default_val(ty, &f.default)?;
Ok(quote! { #ident: #val })
}
}
}
fn gen_flatten_default(f: &FlattenField) -> TokenStream2 {
let ident = &f.ident;
let ty = &f.ty;
quote! { #ident: #ty::defaults() }
}
fn gen_mapped_default(mp: &MappedParent) -> Result<TokenStream2> {
let ident = &mp.ident;
let ty = &mp.ty;
let sub_fields: Vec<_> = mp
.settings
.iter()
.map(|s| {
let sub = format_ident!("{}", s.sub_field);
let val: TokenStream2 = match &s.default_debug {
Some(dbg) => {
let debug = parse_default_tokens(&mp.ty, dbg)?;
let release = parse_default_tokens(&mp.ty, &s.default)?;
quote! { if cfg!(debug_assertions) { #debug } else { #release } }
}
None => parse_default_tokens(&mp.ty, &s.default)?,
};
Ok(quote! { #sub: #val })
})
.collect::<Result<Vec<_>>>()?;
Ok(quote! { #ident: #ty { #(#sub_fields,)* } })
}
fn gen_apply_value(f: &SettingField) -> TokenStream2 {
let ident = &f.ident;
let key = &f.key;
let ty = &f.ty;
if is_type(ty, "String") {
quote! {
if let Some(v) = values.get(#key)
&& !v.is_empty()
{
self.#ident = v.clone();
}
}
} else if is_type(ty, "bool") {
quote! {
if let Some(v) = values.get(#key) {
self.#ident = v == "true" || v == "1";
}
}
} else if is_vec_string(ty) {
quote! {
if let Some(v) = values.get(#key) {
self.#ident = if v.is_empty() {
Vec::new()
} else {
v.split(',').map(|s| s.trim().to_string()).collect()
};
}
}
} else {
quote! {
if let Some(v) = values.get(#key)
&& let Ok(parsed) = v.parse()
{
self.#ident = parsed;
}
}
}
}
fn gen_flatten_apply(f: &FlattenField) -> TokenStream2 {
let ident = &f.ident;
quote! { self.#ident.apply_config_values(values); }
}
fn gen_mapped_apply(mp: &MappedParent) -> TokenStream2 {
let parent = &mp.ident;
let calls: Vec<_> = mp
.settings
.iter()
.map(|s| {
let sub = format_ident!("{}", s.sub_field);
let key = &s.key;
quote! {
if let Some(v) = values.get(#key)
&& let Ok(parsed) = v.parse()
{
self.#parent.#sub = parsed;
}
}
})
.collect();
quote! { #(#calls)* }
}
fn gen_default_setting(f: &SettingField) -> TokenStream2 {
let key = &f.key;
let default = &f.default;
match &f.default_debug {
Some(dbg) => quote! {
settings.push((#key, if cfg!(debug_assertions) { #dbg.to_string() } else { #default.to_string() }));
},
None => quote! {
settings.push((#key, #default.to_string()));
},
}
}
fn gen_flatten_default_settings(f: &FlattenField) -> TokenStream2 {
let ty = &f.ty;
quote! { settings.extend(#ty::default_settings()); }
}
fn gen_mapped_default_settings(mp: &MappedParent) -> TokenStream2 {
let calls: Vec<_> = mp
.settings
.iter()
.map(|s| {
let key = &s.key;
let default = &s.default;
match &s.default_debug {
Some(dbg) => quote! {
settings.push((#key, if cfg!(debug_assertions) { #dbg.to_string() } else { #default.to_string() }));
},
None => quote! {
settings.push((#key, #default.to_string()));
},
}
})
.collect();
quote! { #(#calls)* }
}
fn collect_api_keys(fields: &[ConfigField]) -> Vec<(&str, &str)> {
let mut keys = Vec::new();
for f in fields {
match f {
ConfigField::Setting(s) if s.api => {
let sec = s.section.as_deref().unwrap_or("default");
keys.push((sec, s.key.as_str()));
}
ConfigField::MappedParent(mp) => {
for s in &mp.settings {
if s.api {
let sec = s.section.as_deref().unwrap_or("default");
keys.push((sec, s.key.as_str()));
}
}
}
_ => {}
}
}
keys
}
fn gen_keys_consts(fields: &[ConfigField]) -> TokenStream2 {
let api_keys = collect_api_keys(fields);
if api_keys.is_empty() {
return quote! {};
}
let mut sections: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (sec, key) in &api_keys {
sections.entry(sec).or_default().push(key);
}
let single = sections.len() == 1;
sections
.iter()
.map(|(section, keys)| {
let name = if single {
format_ident!("API_KEYS")
} else {
format_ident!("{}_KEYS", section.to_uppercase())
};
quote! { pub const #name: &[&str] = &[#(#keys),*]; }
})
.collect()
}
fn value_to_string_expr(ty: &Type, expr: TokenStream2) -> TokenStream2 {
if is_type(ty, "String") {
quote! { #expr.clone() }
} else if is_vec_string(ty) {
quote! { #expr.join(",") }
} else {
quote! { #expr.to_string() }
}
}
fn gen_api_values(fields: &[ConfigField]) -> TokenStream2 {
let entries: Vec<_> = fields
.iter()
.flat_map(|f| match f {
ConfigField::Setting(s) if s.api => {
let key = &s.key;
let ident = &s.ident;
let value = value_to_string_expr(&s.ty, quote! { self.#ident });
vec![quote! { values.push((#key, #value)); }]
}
ConfigField::Flatten(f) => {
let ident = &f.ident;
vec![quote! { values.extend(self.#ident.api_values()); }]
}
ConfigField::MappedParent(mp) => mp
.settings
.iter()
.filter(|s| s.api)
.map(|s| {
let key = &s.key;
let parent = &mp.ident;
let sub = format_ident!("{}", s.sub_field);
quote! { values.push((#key, self.#parent.#sub.to_string())); }
})
.collect(),
_ => Vec::new(),
})
.collect();
quote! {
pub fn api_values(&self) -> Vec<(&'static str, String)> {
let mut values = Vec::new();
#(#entries)*
values
}
}
}
pub fn config_settings_impl(attr: TokenStream, item: TokenStream) -> TokenStream {
let struct_attr = syn::parse_macro_input!(attr as StructAttr);
let mut input = syn::parse_macro_input!(item as ItemStruct);
let mapped_settings = match parse_struct_mapped_settings(&mut input, &struct_attr.default_section) {
Ok(settings) => settings,
Err(err) => return err.to_compile_error().into(),
};
let mut mapped_groups: BTreeMap<String, Vec<MappedSetting>> = BTreeMap::new();
for ms in mapped_settings {
mapped_groups.entry(ms.parent.clone()).or_default().push(ms);
}
let fields = match &mut input.fields {
Fields::Named(f) => f,
_ => {
return Error::new(input.span(), "config_settings only supports named fields")
.to_compile_error()
.into();
}
};
let mut config_fields = Vec::new();
for field in &mut fields.named {
let Some(field_ident) = field.ident.clone() else {
return Error::new(field.span(), "config_settings only supports named fields")
.to_compile_error()
.into();
};
let field_name = field_ident.to_string();
if let Some(settings) = mapped_groups.remove(&field_name) {
config_fields.push(ConfigField::MappedParent(MappedParent {
ident: field_ident,
ty: field.ty.clone(),
settings,
}));
} else {
match parse_field(field, &struct_attr.default_section) {
Ok(Some(cf)) => config_fields.push(cf),
Ok(None) => {}
Err(err) => return err.to_compile_error().into(),
}
}
}
let struct_name = &input.ident;
let keys_consts = gen_keys_consts(&config_fields);
let api_values = gen_api_values(&config_fields);
let default_fields: Vec<_> = match config_fields
.iter()
.map(|f| match f {
ConfigField::Setting(s) => gen_default(s),
ConfigField::Flatten(s) => Ok(gen_flatten_default(s)),
ConfigField::MappedParent(mp) => gen_mapped_default(mp),
})
.collect::<Result<Vec<_>>>()
{
Ok(fields) => fields,
Err(err) => return err.to_compile_error().into(),
};
let apply_calls: Vec<_> = config_fields
.iter()
.map(|f| match f {
ConfigField::Setting(s) => gen_apply_value(s),
ConfigField::Flatten(s) => gen_flatten_apply(s),
ConfigField::MappedParent(mp) => gen_mapped_apply(mp),
})
.collect();
let default_setting_calls: Vec<_> = config_fields
.iter()
.map(|f| match f {
ConfigField::Setting(s) => gen_default_setting(s),
ConfigField::Flatten(s) => gen_flatten_default_settings(s),
ConfigField::MappedParent(mp) => gen_mapped_default_settings(mp),
})
.collect();
let expanded = quote! {
#input
impl #struct_name {
#keys_consts
#api_values
pub fn defaults() -> Self {
Self {
#(#default_fields,)*
}
}
pub fn from_config_values(values: &super::ConfigValues) -> Self {
let mut cfg = Self::defaults();
cfg.apply_config_values(values);
cfg
}
pub fn apply_config_values(&mut self, values: &super::ConfigValues) {
#(#apply_calls)*
}
pub fn default_settings() -> Vec<(&'static str, String)> {
let mut settings = Vec::new();
#(#default_setting_calls)*
settings
}
}
};
TokenStream::from(expanded)
}

220
macros/src/error_enum.rs Normal file
View File

@ -0,0 +1,220 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::spanned::Spanned;
use syn::{Attribute, Error, Expr, Ident, LitStr, Result, Token, Type};
pub struct ErrorVariant {
pub attributes: Vec<Attribute>,
pub error_msg: LitStr,
pub name: Ident,
pub fields: Vec<(Ident, Type)>,
pub level: Expr,
}
impl ErrorVariant {
pub fn has_no_source(&self) -> bool {
self.attributes.iter().any(|attr| attr.path().is_ident("no_source"))
}
pub fn should_generate_constructor(&self, force_no_source: bool) -> bool {
if force_no_source || self.has_no_source() {
!self.fields.is_empty()
} else {
true
}
}
}
pub struct ErrorEnumInput {
pub enum_name: Ident,
pub variants: Vec<ErrorVariant>,
}
impl Parse for ErrorEnumInput {
fn parse(input: ParseStream) -> Result<Self> {
let enum_name = input.parse::<Ident>()?;
let content;
syn::braced!(content in input);
let mut variants = Vec::new();
while !content.is_empty() {
let mut attributes = Vec::new();
while content.peek(Token![#]) {
attributes.push(content.call(Attribute::parse_outer)?);
}
let attributes: Vec<_> = attributes.into_iter().flatten().collect();
let error_attr = attributes
.iter()
.find(|attr| attr.path().is_ident("error"))
.ok_or_else(|| Error::new(content.span(), "Missing #[error] attribute"))?;
let error_msg = match &error_attr.meta {
syn::Meta::List(list) => syn::parse2::<LitStr>(list.tokens.clone())?,
_ => {
return Err(Error::new(error_attr.span(), "Invalid error attribute format"));
}
};
let name = content.parse::<Ident>()?;
let mut fields = Vec::new();
if content.peek(syn::token::Brace) {
let fields_content;
syn::braced!(fields_content in content);
while !fields_content.is_empty() {
let field_name = fields_content.parse::<Ident>()?;
fields_content.parse::<Token![:]>()?;
let field_type = fields_content.parse::<Type>()?;
fields.push((field_name, field_type));
if !fields_content.is_empty() {
fields_content.parse::<Token![,]>()?;
}
}
}
content.parse::<Token![=>]>()?;
let level = content.parse::<Expr>()?;
if !content.is_empty() {
content.parse::<Token![,]>()?;
}
variants.push(ErrorVariant {
attributes,
error_msg,
name,
fields,
level,
});
}
Ok(ErrorEnumInput { enum_name, variants })
}
}
pub fn generate_error_enum(input: TokenStream, force_no_source: bool) -> TokenStream {
let input = syn::parse_macro_input!(input as ErrorEnumInput);
let enum_name = &input.enum_name;
let variants = &input.variants;
let enum_variants = variants.iter().map(|variant| {
let name = &variant.name;
let error_msg = &variant.error_msg;
let fields = &variant.fields;
let field_definitions = fields.iter().map(|(name, ty)| {
quote! { #name: #ty }
});
if force_no_source || variant.has_no_source() {
if variant.fields.is_empty() {
quote! {
#[error(#error_msg)]
#name
}
} else {
quote! {
#[error(#error_msg)]
#name { #(#field_definitions,)* }
}
}
} else {
quote! {
#[error(#error_msg)]
#name {
#(#field_definitions,)*
err: String
}
}
}
});
let level_match_arms = variants.iter().map(|variant| {
let name = &variant.name;
let level = &variant.level;
if force_no_source || variant.has_no_source() {
if variant.fields.is_empty() {
quote! {
Self::#name => #level
}
} else {
quote! {
Self::#name { .. } => #level
}
}
} else {
quote! {
Self::#name { err: _, .. } => #level
}
}
});
let constructors = variants.iter().filter_map(|variant| {
if !variant.should_generate_constructor(force_no_source) {
return None;
}
let name = &variant.name;
let fields = &variant.fields;
let params = fields.iter().map(|(field_name, field_type)| {
quote! { #field_name: impl Into<#field_type> }
});
let field_assignments = fields.iter().map(|(field_name, _)| {
quote! { #field_name: #field_name.into() }
});
if force_no_source || variant.has_no_source() {
Some(quote! {
#[allow(non_snake_case)]
pub fn #name(#(#params),*) -> Self {
Self::#name {
#(#field_assignments,)*
}
}
})
} else {
Some(quote! {
#[allow(non_snake_case)]
pub fn #name(#(#params,)* source: impl std::fmt::Display) -> Self {
Self::#name {
#(#field_assignments,)*
err: source.to_string()
}
}
})
}
});
let expanded = quote! {
#[allow(dead_code, clippy::enum_variant_names)]
#[derive(Debug, Clone, thiserror::Error, serde::Serialize, serde::Deserialize)]
pub enum #enum_name {
#(#enum_variants,)*
}
impl #enum_name {
#[allow(dead_code)]
pub fn level(&self) -> tracing::Level {
match self {
#(#level_match_arms,)*
}
}
#(#constructors)*
}
};
TokenStream::from(expanded)
}

187
macros/src/fallible.rs Normal file
View File

@ -0,0 +1,187 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::spanned::Spanned;
use syn::{Attribute, Error, Ident, LitStr, Result, Token, Type};
struct FallibleVariant {
attributes: Vec<Attribute>,
error_msg: LitStr,
name: Ident,
fields: Vec<(Ident, Type)>,
}
impl FallibleVariant {
fn has_no_source(&self) -> bool {
self.attributes.iter().any(|attr| attr.path().is_ident("no_source"))
}
fn should_generate_constructor(&self) -> bool {
if self.has_no_source() {
!self.fields.is_empty()
} else {
true
}
}
}
struct FallibleInput {
enum_name: Ident,
variants: Vec<FallibleVariant>,
}
impl Parse for FallibleInput {
fn parse(input: ParseStream) -> Result<Self> {
let enum_name = input.parse::<Ident>()?;
let content;
syn::braced!(content in input);
let mut variants = Vec::new();
while !content.is_empty() {
let mut attributes = Vec::new();
while content.peek(Token![#]) {
attributes.push(content.call(Attribute::parse_outer)?);
}
let attributes: Vec<_> = attributes.into_iter().flatten().collect();
let error_attr = attributes
.iter()
.find(|attr| attr.path().is_ident("error"))
.ok_or_else(|| Error::new(content.span(), "Missing #[error] attribute"))?;
let error_msg = match &error_attr.meta {
syn::Meta::List(list) => syn::parse2::<LitStr>(list.tokens.clone())?,
_ => {
return Err(Error::new(error_attr.span(), "Invalid error attribute format"));
}
};
let name = content.parse::<Ident>()?;
let mut fields = Vec::new();
if content.peek(syn::token::Brace) {
let fields_content;
syn::braced!(fields_content in content);
while !fields_content.is_empty() {
let field_name = fields_content.parse::<Ident>()?;
fields_content.parse::<Token![:]>()?;
let field_type = fields_content.parse::<Type>()?;
fields.push((field_name, field_type));
if !fields_content.is_empty() {
fields_content.parse::<Token![,]>()?;
}
}
}
if !content.is_empty() {
content.parse::<Token![,]>()?;
}
variants.push(FallibleVariant {
attributes,
error_msg,
name,
fields,
});
}
Ok(FallibleInput { enum_name, variants })
}
}
pub fn fallible_impl(input: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(input as FallibleInput);
let enum_name = &input.enum_name;
let variants = &input.variants;
let enum_variants = variants.iter().map(|variant| {
let name = &variant.name;
let error_msg = &variant.error_msg;
let fields = &variant.fields;
let field_definitions = fields.iter().map(|(name, ty)| {
quote! { #name: #ty }
});
if variant.has_no_source() {
if variant.fields.is_empty() {
quote! {
#[error(#error_msg)]
#name
}
} else {
quote! {
#[error(#error_msg)]
#name { #(#field_definitions,)* }
}
}
} else {
quote! {
#[error(#error_msg)]
#name {
#(#field_definitions,)*
err: String
}
}
}
});
let constructors = variants.iter().filter_map(|variant| {
if !variant.should_generate_constructor() {
return None;
}
let name = &variant.name;
let fields = &variant.fields;
let params = fields.iter().map(|(field_name, field_type)| {
quote! { #field_name: impl Into<#field_type> }
});
let field_assignments = fields.iter().map(|(field_name, _)| {
quote! { #field_name: #field_name.into() }
});
if variant.has_no_source() {
Some(quote! {
#[allow(non_snake_case)]
pub fn #name(#(#params),*) -> Self {
Self::#name {
#(#field_assignments,)*
}
}
})
} else {
Some(quote! {
#[allow(non_snake_case)]
pub fn #name(#(#params,)* source: impl std::fmt::Display) -> Self {
Self::#name {
#(#field_assignments,)*
err: source.to_string()
}
}
})
}
});
let expanded = quote! {
#[allow(dead_code, clippy::enum_variant_names)]
#[derive(Debug, Clone, thiserror::Error)]
pub enum #enum_name {
#(#enum_variants,)*
}
impl #enum_name {
#(#constructors)*
}
};
TokenStream::from(expanded)
}

33
macros/src/lib.rs Normal file
View File

@ -0,0 +1,33 @@
mod config;
mod error_enum;
mod fallible;
mod log;
mod loggable;
mod traceable;
use proc_macro::TokenStream;
#[proc_macro_attribute]
pub fn config_settings(attr: TokenStream, item: TokenStream) -> TokenStream {
config::config_settings_impl(attr, item)
}
#[proc_macro]
pub fn fallible(input: TokenStream) -> TokenStream {
fallible::fallible_impl(input)
}
#[proc_macro]
pub fn log(input: TokenStream) -> TokenStream {
log::log_impl(input)
}
#[proc_macro]
pub fn loggable(input: TokenStream) -> TokenStream {
loggable::loggable_impl(input)
}
#[proc_macro]
pub fn traceable(input: TokenStream) -> TokenStream {
traceable::traceable_impl(input)
}

66
macros/src/log.rs Normal file
View File

@ -0,0 +1,66 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::{Expr, Token, parse_macro_input};
struct LogInput {
error: Expr,
debug_info: Option<Expr>,
}
impl Parse for LogInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
let error = input.parse::<Expr>()?;
let debug_info = if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
Some(input.parse::<Expr>()?)
} else {
None
};
Ok(LogInput { error, debug_info })
}
}
pub fn log_impl(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as LogInput);
let error_expr = &input.error;
if let Some(debug_info) = &input.debug_info {
quote! {
{
let error = #error_expr;
let level = error.level();
let message = error.to_string();
let debug_info = #debug_info;
match level {
tracing::Level::ERROR => tracing::error!(message = %message, debug = ?debug_info),
tracing::Level::WARN => tracing::warn!(message = %message, debug = ?debug_info),
tracing::Level::INFO => tracing::info!(message = %message, debug = ?debug_info),
tracing::Level::DEBUG => tracing::debug!(message = %message, debug = ?debug_info),
tracing::Level::TRACE => tracing::trace!(message = %message, debug = ?debug_info),
}
}
}
} else {
quote! {
{
let error = #error_expr;
let level = error.level();
let message = error.to_string();
match level {
tracing::Level::ERROR => tracing::error!("{}", message),
tracing::Level::WARN => tracing::warn!("{}", message),
tracing::Level::INFO => tracing::info!("{}", message),
tracing::Level::DEBUG => tracing::debug!("{}", message),
tracing::Level::TRACE => tracing::trace!("{}", message),
}
}
}
}
.into()
}

7
macros/src/loggable.rs Normal file
View File

@ -0,0 +1,7 @@
use proc_macro::TokenStream;
use crate::error_enum;
pub fn loggable_impl(input: TokenStream) -> TokenStream {
error_enum::generate_error_enum(input, true)
}

7
macros/src/traceable.rs Normal file
View File

@ -0,0 +1,7 @@
use proc_macro::TokenStream;
use crate::error_enum;
pub fn traceable_impl(input: TokenStream) -> TokenStream {
error_enum::generate_error_enum(input, false)
}

BIN
models/classifier.onnx Normal file

Binary file not shown.

Binary file not shown.

336
models/full_config.json Normal file
View File

@ -0,0 +1,336 @@
{
"created_at": "2026-04-07T02:27:17.281069",
"framework": "PyTorch",
"model_type": "pipeline",
"model": {
"deep_autoencoder": {
"file": "deep_autoencoder.onnx",
"input_dim": 31,
"encoding_dim": 12,
"ae_feature_names": [
"flow_duration",
"fwd_packets",
"bwd_packets",
"fwd_bytes",
"bwd_bytes",
"flow_bytes_per_sec",
"flow_pkts_per_sec",
"fwd_win_bytes",
"bwd_win_bytes",
"fwd_pkt_len_mean",
"bwd_pkt_len_mean",
"fwd_iat_mean",
"bwd_iat_mean",
"flow_iat_mean",
"pkt_len_mean",
"dst_port",
"protocol",
"psh_flag_cnt",
"ack_flag_cnt",
"syn_flag_cnt",
"fin_flag_cnt",
"rst_flag_cnt",
"pkt_len_std",
"fwd_pkt_len_std",
"bwd_pkt_len_std",
"fwd_seg_size_min",
"fwd_act_data_pkts",
"fwd_iat_std",
"bwd_iat_std",
"fwd_bwd_bytes_ratio",
"iat_cv"
],
"ae_threshold": 0.23011694848537445
},
"classifier": {
"file": "classifier.onnx",
"type": "classifier",
"n_features": 32,
"n_classes": 10,
"outputs": [
"anomaly",
"class_probs",
"c2_score"
],
"classifier_feature_names": [
"flow_duration",
"fwd_packets",
"bwd_packets",
"fwd_bytes",
"bwd_bytes",
"flow_bytes_per_sec",
"flow_pkts_per_sec",
"fwd_win_bytes",
"bwd_win_bytes",
"fwd_pkt_len_mean",
"bwd_pkt_len_mean",
"fwd_iat_mean",
"bwd_iat_mean",
"flow_iat_mean",
"pkt_len_mean",
"dst_port",
"protocol",
"psh_flag_cnt",
"ack_flag_cnt",
"syn_flag_cnt",
"fin_flag_cnt",
"rst_flag_cnt",
"pkt_len_std",
"fwd_pkt_len_std",
"bwd_pkt_len_std",
"fwd_seg_size_min",
"fwd_act_data_pkts",
"fwd_iat_std",
"bwd_iat_std",
"fwd_bwd_bytes_ratio",
"iat_cv",
"ae_anomaly_score"
]
}
},
"preprocessing": {
"ae_clip_params": {
"flow_duration": {
"lower": 0.0,
"upper": 115669365.2
},
"fwd_packets": {
"lower": 0.0,
"upper": 120.0
},
"bwd_packets": {
"lower": 0.0,
"upper": 126.0
},
"fwd_bytes": {
"lower": 0.0,
"upper": 19557.400390625
},
"bwd_bytes": {
"lower": 0.0,
"upper": 85164.0
},
"flow_bytes_per_sec": {
"lower": 0.0,
"upper": 1627586.8125000005
},
"flow_pkts_per_sec": {
"lower": 0.0,
"upper": 23809.5234375
},
"fwd_win_bytes": {
"lower": 0.0,
"upper": 65280.0
},
"bwd_win_bytes": {
"lower": 0.0,
"upper": 65535.0
},
"fwd_pkt_len_mean": {
"lower": 0.0,
"upper": 1500.0
},
"bwd_pkt_len_mean": {
"lower": 0.0,
"upper": 822.0007794189461
},
"fwd_iat_mean": {
"lower": 0.0,
"upper": 54051113.24
},
"bwd_iat_mean": {
"lower": 0.0,
"upper": 6912790.715000001
},
"flow_iat_mean": {
"lower": 0.0,
"upper": 166521472.0
},
"pkt_len_mean": {
"lower": 0.0,
"upper": 957.2035284423835
},
"dst_port": {
"lower": 0.0,
"upper": 63005.0
},
"protocol": {
"lower": 0.0,
"upper": 17.0
},
"psh_flag_cnt": {
"lower": 0.0,
"upper": 52.0
},
"ack_flag_cnt": {
"lower": 0.0,
"upper": 107.0
},
"syn_flag_cnt": {
"lower": 0.0,
"upper": 4.0
},
"fin_flag_cnt": {
"lower": 0.0,
"upper": 1.0
},
"rst_flag_cnt": {
"lower": 0.0,
"upper": 0.0
},
"pkt_len_std": {
"lower": 0.0,
"upper": 818.4579974365238
},
"fwd_pkt_len_std": {
"lower": 0.0,
"upper": 256.8401712036142
},
"bwd_pkt_len_std": {
"lower": 0.0,
"upper": 676.0667114257812
},
"fwd_seg_size_min": {
"lower": 0.0,
"upper": 1026743.0693750025
},
"fwd_act_data_pkts": {
"lower": 0.0,
"upper": 12.0
},
"fwd_iat_std": {
"lower": 0.0,
"upper": 6691987.085000001
},
"bwd_iat_std": {
"lower": 0.0,
"upper": 5136363.065000001
},
"fwd_bwd_bytes_ratio": {
"lower": 0.0,
"upper": 1.0
},
"iat_cv": {
"lower": 0.0,
"upper": 0.0
}
},
"ae_scaler": {
"mean": [
1775639.8280735926,
5.542641564702501,
4.873551306800804,
673.3628917399571,
1776.7162625946232,
22595.98254433581,
457.7205079100132,
5370.204067202503,
9979.409366405764,
310.21111530262294,
64.0225296706552,
421007.8046985764,
74954.24060464761,
1927814.4699678936,
18.27810422291583,
10009.108501605231,
9.275900254913996,
0.6270530398000678,
1.4141691028300247,
0.08438195832759936,
0.0413846397252831,
0.0,
20.458150398533718,
3.321724142251631,
13.033036407393814,
8569.843223681366,
0.21485243990919378,
94146.99057411935,
68775.38334652747,
0.47595050130443944,
0.0
],
"std": [
12196313.175317517,
13.15004546194127,
13.245688945052057,
1969.9160973703263,
8250.018585629565,
161540.2407042193,
2518.5714986775442,
12103.605209582642,
20308.80085611352,
476.8868336260097,
95.72217224515282,
4199427.656867863,
616719.9756782106,
16437665.614047276,
97.4688620675193,
18194.33523865822,
5.182633726939228,
4.994559084102198,
10.431975160428792,
0.558462828085695,
0.19916731632770637,
1.0,
106.06236469581468,
24.362268530572912,
85.37603561474889,
84340.18813365103,
1.4442281045462682,
646928.7442307192,
525550.9737726098,
0.3431291415218137,
1.0
],
"feature_names": [
"flow_duration",
"fwd_packets",
"bwd_packets",
"fwd_bytes",
"bwd_bytes",
"flow_bytes_per_sec",
"flow_pkts_per_sec",
"fwd_win_bytes",
"bwd_win_bytes",
"fwd_pkt_len_mean",
"bwd_pkt_len_mean",
"fwd_iat_mean",
"bwd_iat_mean",
"flow_iat_mean",
"pkt_len_mean",
"dst_port",
"protocol",
"psh_flag_cnt",
"ack_flag_cnt",
"syn_flag_cnt",
"fin_flag_cnt",
"rst_flag_cnt",
"pkt_len_std",
"fwd_pkt_len_std",
"bwd_pkt_len_std",
"fwd_seg_size_min",
"fwd_act_data_pkts",
"fwd_iat_std",
"bwd_iat_std",
"fwd_bwd_bytes_ratio",
"iat_cv"
]
},
"post_scaling_clip": {
"min": -5.0,
"max": 5.0
}
},
"attack_labels": {
"0": "Bot",
"1": "Brute Force",
"2": "C2 Communication",
"3": "DNS Tunneling",
"4": "DoS\/DDoS",
"5": "Exploitation",
"6": "Malware",
"7": "Normal",
"8": "Reconnaissance",
"9": "Web Attack"
}
}

View File

@ -0,0 +1,266 @@
{
"ae_feature_names": [
"flow_duration",
"fwd_packets",
"bwd_packets",
"fwd_bytes",
"bwd_bytes",
"flow_bytes_per_sec",
"flow_pkts_per_sec",
"fwd_win_bytes",
"bwd_win_bytes",
"fwd_pkt_len_mean",
"bwd_pkt_len_mean",
"fwd_iat_mean",
"bwd_iat_mean",
"flow_iat_mean",
"pkt_len_mean",
"dst_port",
"protocol",
"psh_flag_cnt",
"ack_flag_cnt",
"syn_flag_cnt",
"fin_flag_cnt",
"rst_flag_cnt",
"pkt_len_std",
"fwd_pkt_len_std",
"bwd_pkt_len_std",
"fwd_seg_size_min",
"fwd_act_data_pkts",
"fwd_iat_std",
"bwd_iat_std",
"fwd_bwd_bytes_ratio",
"iat_cv"
],
"ae_clip_params": {
"flow_duration": {
"lower": 0,
"upper": 115669365.2
},
"fwd_packets": {
"lower": 0,
"upper": 120
},
"bwd_packets": {
"lower": 0,
"upper": 126
},
"fwd_bytes": {
"lower": 0,
"upper": 19557.400390625
},
"bwd_bytes": {
"lower": 0,
"upper": 85164
},
"flow_bytes_per_sec": {
"lower": 0,
"upper": 1627586.8125000005
},
"flow_pkts_per_sec": {
"lower": 0,
"upper": 23809.5234375
},
"fwd_win_bytes": {
"lower": 0,
"upper": 65280
},
"bwd_win_bytes": {
"lower": 0,
"upper": 65535
},
"fwd_pkt_len_mean": {
"lower": 0,
"upper": 1500
},
"bwd_pkt_len_mean": {
"lower": 0,
"upper": 822.0007794189461
},
"fwd_iat_mean": {
"lower": 0,
"upper": 54051113.24
},
"bwd_iat_mean": {
"lower": 0,
"upper": 6912790.715000001
},
"flow_iat_mean": {
"lower": 0,
"upper": 166521472
},
"pkt_len_mean": {
"lower": 0,
"upper": 957.2035284423835
},
"dst_port": {
"lower": 0,
"upper": 63005
},
"protocol": {
"lower": 0,
"upper": 17
},
"psh_flag_cnt": {
"lower": 0,
"upper": 52
},
"ack_flag_cnt": {
"lower": 0,
"upper": 107
},
"syn_flag_cnt": {
"lower": 0,
"upper": 4
},
"fin_flag_cnt": {
"lower": 0,
"upper": 1
},
"rst_flag_cnt": {
"lower": 0,
"upper": 0
},
"pkt_len_std": {
"lower": 0,
"upper": 818.4579974365238
},
"fwd_pkt_len_std": {
"lower": 0,
"upper": 256.8401712036142
},
"bwd_pkt_len_std": {
"lower": 0,
"upper": 676.0667114257812
},
"fwd_seg_size_min": {
"lower": 0,
"upper": 1026743.0693750025
},
"fwd_act_data_pkts": {
"lower": 0,
"upper": 12
},
"fwd_iat_std": {
"lower": 0,
"upper": 6691987.085000001
},
"bwd_iat_std": {
"lower": 0,
"upper": 5136363.065000001
},
"fwd_bwd_bytes_ratio": {
"lower": 0,
"upper": 1
},
"iat_cv": {
"lower": 0,
"upper": 0
}
},
"ae_scaler_mean": [
1775639.8280735926,
5.542641564702501,
4.873551306800804,
673.3628917399571,
1776.7162625946232,
22595.98254433581,
457.7205079100132,
5370.204067202503,
9979.409366405764,
310.21111530262294,
64.0225296706552,
421007.8046985764,
74954.24060464761,
1927814.4699678936,
18.27810422291583,
10009.108501605231,
9.275900254913996,
0.6270530398000678,
1.4141691028300247,
0.08438195832759936,
0.0413846397252831,
0,
20.458150398533718,
3.321724142251631,
13.033036407393814,
8569.843223681366,
0.21485243990919378,
94146.99057411935,
68775.38334652747,
0.47595050130443944,
0
],
"ae_scaler_std": [
12196313.175317517,
13.15004546194127,
13.245688945052057,
1969.9160973703263,
8250.018585629565,
161540.2407042193,
2518.5714986775442,
12103.605209582642,
20308.80085611352,
476.8868336260097,
95.72217224515282,
4199427.656867863,
616719.9756782106,
16437665.614047276,
97.4688620675193,
18194.33523865822,
5.182633726939228,
4.994559084102198,
10.431975160428792,
0.558462828085695,
0.19916731632770637,
1,
106.06236469581468,
24.362268530572912,
85.37603561474889,
84340.18813365103,
1.4442281045462682,
646928.7442307192,
525550.9737726098,
0.3431291415218137,
1
],
"ae_post_clip_min": -5,
"ae_post_clip_max": 5,
"classifier_feature_names": [
"flow_duration",
"fwd_packets",
"bwd_packets",
"fwd_bytes",
"bwd_bytes",
"flow_bytes_per_sec",
"flow_pkts_per_sec",
"fwd_win_bytes",
"bwd_win_bytes",
"fwd_pkt_len_mean",
"bwd_pkt_len_mean",
"fwd_iat_mean",
"bwd_iat_mean",
"flow_iat_mean",
"pkt_len_mean",
"dst_port",
"protocol",
"psh_flag_cnt",
"ack_flag_cnt",
"syn_flag_cnt",
"fin_flag_cnt",
"rst_flag_cnt",
"pkt_len_std",
"fwd_pkt_len_std",
"bwd_pkt_len_std",
"fwd_seg_size_min",
"fwd_act_data_pkts",
"fwd_iat_std",
"bwd_iat_std",
"fwd_bwd_bytes_ratio",
"iat_cv",
"ae_anomaly_score"
],
"minmax_params": {},
"robust_params": {},
"quantile_params": {}
}

182
models/manifest.yaml Normal file
View File

@ -0,0 +1,182 @@
name: netguardia-v1
version: 1
runtime:
pipeline_mode: dag
normal_label: Normal
artifacts:
- id: anomaly_detector_onnx
file: deep_autoencoder.onnx
kind: onnx
- id: classifier_onnx
file: classifier.onnx
kind: onnx
- id: preprocessing_sidecar
file: inference_config.json
kind: sidecar
stages:
- id: anomaly_detector
kind: autoencoder
model_file: deep_autoencoder.onnx
inputs:
- { name: flow_duration, source: feature }
- { name: fwd_packets, source: feature }
- { name: bwd_packets, source: feature }
- { name: fwd_bytes, source: feature }
- { name: bwd_bytes, source: feature }
- { name: flow_bytes_per_sec, source: feature }
- { name: flow_pkts_per_sec, source: feature }
- { name: fwd_win_bytes, source: feature }
- { name: bwd_win_bytes, source: feature }
- { name: fwd_pkt_len_mean, source: feature }
- { name: bwd_pkt_len_mean, source: feature }
- { name: fwd_iat_mean, source: feature }
- { name: bwd_iat_mean, source: feature }
- { name: flow_iat_mean, source: feature }
- { name: pkt_len_mean, source: feature }
- { name: dst_port, source: feature }
- { name: protocol, source: feature }
- { name: psh_flag_cnt, source: feature }
- { name: ack_flag_cnt, source: feature }
- { name: syn_flag_cnt, source: feature }
- { name: fin_flag_cnt, source: feature }
- { name: rst_flag_cnt, source: feature }
- { name: pkt_len_std, source: feature }
- { name: fwd_pkt_len_std, source: feature }
- { name: bwd_pkt_len_std, source: feature }
- { name: fwd_seg_size_min, source: feature }
- { name: fwd_act_data_pkts, source: feature }
- { name: fwd_iat_std, source: feature }
- { name: bwd_iat_std, source: feature }
- { name: fwd_bwd_bytes_ratio, source: feature }
- { name: iat_cv, source: feature }
preprocessing:
- type: standard_scaler
sidecar: inference_config.json
- type: clip
min: -5.0
max: 5.0
output_heads:
- name: ae_anomaly_score
index: 0
shape: [1]
semantic: anomaly_score
threshold: 0.23011694848537445
- id: classifier
kind: classifier
model_file: classifier.onnx
depends_on:
- anomaly_detector
inputs:
- { name: flow_duration, source: feature }
- { name: fwd_packets, source: feature }
- { name: bwd_packets, source: feature }
- { name: fwd_bytes, source: feature }
- { name: bwd_bytes, source: feature }
- { name: flow_bytes_per_sec, source: feature }
- { name: flow_pkts_per_sec, source: feature }
- { name: fwd_win_bytes, source: feature }
- { name: bwd_win_bytes, source: feature }
- { name: fwd_pkt_len_mean, source: feature }
- { name: bwd_pkt_len_mean, source: feature }
- { name: fwd_iat_mean, source: feature }
- { name: bwd_iat_mean, source: feature }
- { name: flow_iat_mean, source: feature }
- { name: pkt_len_mean, source: feature }
- { name: dst_port, source: feature }
- { name: protocol, source: feature }
- { name: psh_flag_cnt, source: feature }
- { name: ack_flag_cnt, source: feature }
- { name: syn_flag_cnt, source: feature }
- { name: fin_flag_cnt, source: feature }
- { name: rst_flag_cnt, source: feature }
- { name: pkt_len_std, source: feature }
- { name: fwd_pkt_len_std, source: feature }
- { name: bwd_pkt_len_std, source: feature }
- { name: fwd_seg_size_min, source: feature }
- { name: fwd_act_data_pkts, source: feature }
- { name: fwd_iat_std, source: feature }
- { name: bwd_iat_std, source: feature }
- { name: fwd_bwd_bytes_ratio, source: feature }
- { name: iat_cv, source: feature }
- name: ae_anomaly_score
source: stage_output
stage: anomaly_detector
output: ae_anomaly_score
output_heads:
- name: anomaly
index: 0
shape: [1]
semantic: binary
threshold: 0.9179317355155945
- name: class_probs
index: 1
shape: [10]
semantic: multiclass
min_confidence: 0.4
- name: c2_score
index: 2
shape: [1]
semantic: binary
threshold: 0.9085615873336792
outputs:
- stage: anomaly_detector
output: ae_anomaly_score
alias: ae_anomaly_score
role: anomaly_score
- stage: classifier
output: anomaly
alias: anomaly
role: binary_score
- stage: classifier
output: class_probs
alias: class_probs
role: class_probabilities
- stage: classifier
output: c2_score
alias: c2_score
role: c2_score
detection_rules:
- id: classifier_anomaly_threshold
type: threshold
output: anomaly
attack:
source: predicted_class
output: class_probs
exclude_normal: true
- id: class_confidence
type: class_confidence
output: class_probs
attack:
source: predicted_class
output: class_probs
exclude_normal: true
- id: c2_threshold
type: threshold
output: c2_score
attack:
source: fixed_label
label: C2 Communication
labels:
"0": { name: Bot, confirmations: 1 }
"1": { name: Brute Force }
"2": { name: C2 Communication, confirmations: 1 }
"3": { name: DNS Tunneling, confirmations: 1 }
"4": { name: DoS/DDoS, confirmations: 2 }
"5": { name: Exploitation, confirmations: 1 }
"6": { name: Malware }
"7": { name: Normal }
"8": { name: Reconnaissance }
"9": { name: Web Attack }
alert_rules:
- condition: "anomaly > threshold"
source_label: anomaly
- condition: "class_probs.argmax != Normal AND class_probs.max > min_confidence"
source_label: class_probs

View File

@ -1,15 +1,17 @@
[package]
name = "net-guardia-common"
name = "net-guardia-abi"
version = "0.1.0"
edition = "2024"
[features]
default = []
user = ["aya"]
user = ["aya", "serde"]
kernel = []
[dependencies]
aya = { workspace = true, optional = true }
network-types = "0.0.7"
serde = { workspace = true, optional = true }
network-types = { workspace = true }
[lib]
path = "src/lib.rs"

View File

@ -0,0 +1,8 @@
pub const DROP_REASON_ACL_BLACKLIST: u8 = 1;
pub const DROP_REASON_RATE_LIMIT_PKT: u8 = 2;
pub const DROP_REASON_RATE_LIMIT_SYN: u8 = 3;
pub const DROP_REASON_RATE_LIMIT_UDP: u8 = 4;
pub const DROP_REASON_RATE_LIMIT_DNS: u8 = 5;
pub const DROP_REASON_PROTOCOL_FILTER: u8 = 6;
pub const DROP_REASON_GEO_BLOCK: u8 = 7;
pub const DROP_REASON_DNS_BLACKLIST: u8 = 8;

View File

@ -0,0 +1,6 @@
pub mod drop_reason;
pub mod offset;
pub mod pipeline;
pub mod rate_limit;
pub mod setting;
pub mod tcp_flags;

View File

@ -0,0 +1,34 @@
use core::mem::size_of;
use network_types::eth::EthHdr;
use network_types::ip::{Ipv4Hdr, Ipv6Hdr};
use network_types::tcp::TcpHdr;
use network_types::udp::UdpHdr;
pub const ETHER_HEADER_START: usize = 0;
pub const ETHER_HEADER_END: usize = ETHER_HEADER_START + size_of::<EthHdr>();
pub const IPV4_HEADER_START: usize = ETHER_HEADER_END;
pub const IPV4_HEADER_END: usize = IPV4_HEADER_START + size_of::<Ipv4Hdr>();
pub const IPV6_HEADER_START: usize = ETHER_HEADER_END;
pub const IPV6_HEADER_END: usize = IPV6_HEADER_START + size_of::<Ipv6Hdr>();
pub const IPV4_TCP_HEADER_START: usize = IPV4_HEADER_END;
pub const IPV4_TCP_HEADER_END: usize = IPV4_TCP_HEADER_START + size_of::<TcpHdr>();
pub const IPV6_TCP_HEADER_START: usize = IPV6_HEADER_END;
pub const IPV6_TCP_HEADER_END: usize = IPV6_TCP_HEADER_START + size_of::<TcpHdr>();
pub const IPV4_UDP_HEADER_START: usize = IPV4_HEADER_END;
pub const IPV4_UDP_HEADER_END: usize = IPV4_UDP_HEADER_START + size_of::<UdpHdr>();
pub const IPV6_UDP_HEADER_START: usize = IPV6_HEADER_END;
pub const IPV6_UDP_HEADER_END: usize = IPV6_UDP_HEADER_START + size_of::<UdpHdr>();
#[cfg(not(feature = "user"))]
const _: () = {
assert!(size_of::<EthHdr>() == 14);
assert!(size_of::<Ipv4Hdr>() == 20);
assert!(size_of::<Ipv6Hdr>() == 40);
};

View File

@ -0,0 +1,7 @@
pub const MAX_STAGES: u32 = 8;
pub const STAGE_NONE: u32 = u32::MAX;
pub const STAGE_ENTRY: u32 = 0;
pub const STAGE_ACCESS_CONTROL: u32 = 1;
pub const STAGE_RATE_LIMIT: u32 = 2;
pub const STAGE_SERVICE: u32 = 3;
pub const STAGE_TRANSMISSION: u32 = 7;

View File

@ -0,0 +1,5 @@
pub const CFG_PACKET_RATE: u32 = 0;
pub const CFG_SYN_RATE: u32 = 1;
pub const CFG_UDP_RATE: u32 = 2;
pub const CFG_DNS_RATE: u32 = 3;
pub const CFG_WINDOW_NS: u32 = 4;

View File

@ -0,0 +1,10 @@
pub const MAX_STATS: usize = 131072;
pub const MAX_RULES: usize = 128;
pub const MAX_RULES_PORT: usize = 32;
pub const MAX_TRACKED_IPS: u32 = 65536;
pub const DEFAULT_WINDOW_NS: u64 = 1_000_000_000;
pub const DEFAULT_PACKET_RATE: u64 = 10000;
pub const DEFAULT_SYN_RATE: u64 = 100;
pub const DEFAULT_UDP_RATE: u64 = 5000;
pub const DEFAULT_DNS_RATE: u64 = 200;
pub const MAX_GEO_ENTRIES: u32 = 131072;

View File

@ -0,0 +1,8 @@
pub const TCP_FIN: u8 = 0x01;
pub const TCP_SYN: u8 = 0x02;
pub const TCP_RST: u8 = 0x04;
pub const TCP_PSH: u8 = 0x08;
pub const TCP_ACK: u8 = 0x10;
pub const TCP_URG: u8 = 0x20;
pub const TCP_ECE: u8 = 0x40;
pub const TCP_CWR: u8 = 0x80;

View File

@ -0,0 +1,2 @@
pub mod parsing;
pub mod symmetric_hash;

View File

@ -0,0 +1,186 @@
use core::mem::size_of;
use core::ptr;
use network_types::eth::{EthHdr, EtherType};
use network_types::ip::{IpProto, Ipv4Hdr, Ipv6Hdr};
use network_types::tcp::TcpHdr;
use network_types::udp::UdpHdr;
use crate::define::offset::*;
use crate::model::ip_address::IpVersion;
use crate::model::parsed_packet::ParsedPacket;
pub unsafe fn parse_packet(start: usize, end: usize, target: *mut ParsedPacket) -> Option<()> {
unsafe {
if start + ETHER_HEADER_END > end {
return None;
}
let eth = &*((start + ETHER_HEADER_START) as *const EthHdr);
let ether_type = eth.ether_type().ok()?;
match ether_type {
EtherType::Ipv4 => parse_ipv4_packet(start, end, target),
EtherType::Ipv6 => parse_ipv6_packet(start, end, target),
_ => None,
}
}
}
#[inline(always)]
unsafe fn parse_ipv4_packet(start: usize, end: usize, target: *mut ParsedPacket) -> Option<()> {
if start + IPV4_HEADER_END > end {
return None;
}
unsafe {
let ipv4 = &*((start + IPV4_HEADER_START) as *const Ipv4Hdr);
let ipv4_header_len = parse_ipv4_header_len(start, end)?;
let l4_start = IPV4_HEADER_START + ipv4_header_len;
let ip_total_len = read_be_u16(start, end, IPV4_HEADER_START + 2)? as usize;
if ip_total_len < ipv4_header_len {
return None;
}
let transport_len = ip_total_len - ipv4_header_len;
let packet_length = ip_total_len as u32;
let t = &mut *target;
ptr::copy_nonoverlapping(ipv4.src_addr.as_ptr(), t.src_ip.as_mut_ptr(), 4);
ptr::copy_nonoverlapping(ipv4.dst_addr.as_ptr(), t.dst_ip.as_mut_ptr(), 4);
t.packet_length = packet_length;
t.ip_version = IpVersion::V4.as_u8();
t.protocol = ipv4.proto;
let (src_port, dst_port, tcp_flags, l4_header_len, transport_len) = match ipv4.proto {
value if value == IpProto::Tcp as u8 => parse_tcp(start, end, l4_start, transport_len)?,
value if value == IpProto::Udp as u8 => parse_udp(start, end, l4_start, transport_len)?,
_ => (0, 0, 0, 0, 0),
};
t.payload_length = (transport_len as u32).saturating_sub(l4_header_len as u32);
t.src_port = src_port;
t.dst_port = dst_port;
t.tcp_flags = tcp_flags;
}
Some(())
}
#[inline(always)]
unsafe fn parse_ipv6_packet(start: usize, end: usize, target: *mut ParsedPacket) -> Option<()> {
if start + IPV6_HEADER_END > end {
return None;
}
unsafe {
let ipv6 = &*((start + IPV6_HEADER_START) as *const Ipv6Hdr);
let payload_len = read_be_u16(start, end, IPV6_HEADER_START + 4)? as usize;
let packet_length = (IPV6_HEADER_END - IPV6_HEADER_START + payload_len) as u32;
let t = &mut *target;
ptr::copy_nonoverlapping(ipv6.src_addr.as_ptr(), t.src_ip.as_mut_ptr(), 16);
ptr::copy_nonoverlapping(ipv6.dst_addr.as_ptr(), t.dst_ip.as_mut_ptr(), 16);
t.packet_length = packet_length;
t.ip_version = IpVersion::V6.as_u8();
t.protocol = ipv6.next_hdr;
let (src_port, dst_port, tcp_flags, l4_header_len, transport_len) = match ipv6.next_hdr {
value if value == IpProto::Tcp as u8 => parse_tcp(start, end, IPV6_TCP_HEADER_START, payload_len)?,
value if value == IpProto::Udp as u8 => parse_udp(start, end, IPV6_UDP_HEADER_START, payload_len)?,
_ => (0, 0, 0, 0, 0),
};
t.payload_length = (transport_len as u32).saturating_sub(l4_header_len as u32);
t.src_port = src_port;
t.dst_port = dst_port;
t.tcp_flags = tcp_flags;
}
Some(())
}
#[inline(always)]
unsafe fn parse_ipv4_header_len(start: usize, end: usize) -> Option<usize> {
if start + IPV4_HEADER_START + 1 > end {
return None;
}
let version_ihl = unsafe { *((start + IPV4_HEADER_START) as *const u8) };
let version = version_ihl >> 4;
let ihl = (version_ihl & 0x0f) as usize;
if version != 4 || !(5..=15).contains(&ihl) {
return None;
}
let header_len = ihl * 4;
if start + IPV4_HEADER_START + header_len > end {
return None;
}
Some(header_len)
}
#[inline(always)]
unsafe fn parse_tcp(
start: usize,
end: usize,
tcp_start: usize,
transport_len: usize,
) -> Option<(u16, u16, u8, usize, usize)> {
if start + tcp_start + size_of::<TcpHdr>() > end {
return None;
}
if transport_len < size_of::<TcpHdr>() {
return None;
}
unsafe {
let tcp = &*((start + tcp_start) as *const TcpHdr);
let data_offset = (*((start + tcp_start + 12) as *const u8) >> 4) as usize;
if !(5..=15).contains(&data_offset) {
return None;
}
let header_len = data_offset * 4;
if header_len > transport_len || start + tcp_start + header_len > end {
return None;
}
let flags = *((start + tcp_start + 13) as *const u8);
Some((
u16::from_be_bytes(tcp.source),
u16::from_be_bytes(tcp.dest),
flags,
header_len,
transport_len,
))
}
}
#[inline(always)]
unsafe fn parse_udp(
start: usize,
end: usize,
udp_start: usize,
transport_len: usize,
) -> Option<(u16, u16, u8, usize, usize)> {
if start + udp_start + size_of::<UdpHdr>() > end {
return None;
}
if transport_len < size_of::<UdpHdr>() {
return None;
}
let udp = unsafe { &*((start + udp_start) as *const UdpHdr) };
let udp_len = udp.len() as usize;
if udp_len < size_of::<UdpHdr>() || udp_len > transport_len {
return None;
}
Some((udp.src_port(), udp.dst_port(), 0u8, 8usize, udp_len))
}
#[inline(always)]
fn read_be_u16(start: usize, end: usize, offset: usize) -> Option<u16> {
if start + offset + 2 > end {
return None;
}
let hi = unsafe { *((start + offset) as *const u8) };
let lo = unsafe { *((start + offset + 1) as *const u8) };
Some(u16::from_be_bytes([hi, lo]))
}

View File

@ -0,0 +1,25 @@
use crate::model::ip_address::IpVersion;
use crate::model::parsed_packet::ParsedPacket;
#[inline(always)]
pub fn symmetric_queue_id(pkt: &ParsedPacket, num_queues: u32) -> Option<u32> {
if num_queues == 0 {
return None;
}
let ip_hash = match pkt.ip_version {
value if value == IpVersion::V4.as_u8() => pkt.src_ip_v4() ^ pkt.dst_ip_v4(),
value if value == IpVersion::V6.as_u8() => {
let s = pkt.src_ip_v6();
let d = pkt.dst_ip_v6();
let xor = s ^ d;
(xor as u32) ^ ((xor >> 32) as u32) ^ ((xor >> 64) as u32) ^ ((xor >> 96) as u32)
}
_ => return None,
};
let port_hash = (pkt.src_port as u32) ^ (pkt.dst_port as u32);
let h = (ip_hash ^ port_hash.rotate_left(16) ^ pkt.protocol as u32).wrapping_mul(2654435761);
Some(h % num_queues)
}

View File

@ -0,0 +1,9 @@
#![no_std]
#[cfg(feature = "user")]
extern crate std;
pub mod define;
#[cfg(feature = "kernel")]
pub mod ebpf;
pub mod model;

View File

@ -0,0 +1,17 @@
#[cfg(feature = "user")]
use aya::Pod;
#[repr(C)]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct DnsName {
pub data: [u8; 128],
}
impl DnsName {
pub const fn zeroed() -> Self {
Self { data: [0u8; 128] }
}
}
#[cfg(feature = "user")]
unsafe impl Pod for DnsName {}

View File

@ -0,0 +1,13 @@
#[repr(C, align(8))]
#[derive(Clone, Copy)]
pub struct DropEvent {
pub timestamp_ns: u64,
pub src_ip: [u8; 16],
pub dst_ip: [u8; 16],
pub src_port: u16,
pub dst_port: u16,
pub protocol: u8,
pub reason: u8,
pub ip_version: u8,
pub _pad: u8,
}

View File

@ -0,0 +1 @@
pub type EmptyMapValue = u8;

View File

@ -0,0 +1,26 @@
#[cfg(feature = "user")]
use aya::Pod;
#[cfg(feature = "user")]
use serde::Serialize;
#[repr(C, align(8))]
#[derive(Clone, Copy)]
#[cfg_attr(feature = "user", derive(Serialize, Debug))]
pub struct FlowStats {
pub bytes: u64,
pub packets: u64,
pub last_seen: u64,
}
impl FlowStats {
pub fn new(bytes: u64, packets: u64, last_seen: u64) -> Self {
Self {
bytes,
packets,
last_seen,
}
}
}
#[cfg(feature = "user")]
unsafe impl Pod for FlowStats {}

View File

@ -1,7 +1,13 @@
use serde::{Deserialize, Serialize};
use net_guardia_common::model::http_method::EbpfHttpMethod;
#[cfg(feature = "user")]
use std::vec::Vec;
#[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)]
#[cfg(feature = "user")]
use serde::{Deserialize, Serialize};
pub type HttpMethodBitmap = u16;
#[derive(Copy, Clone)]
#[cfg_attr(feature = "user", derive(Serialize, Deserialize, Debug, Eq, PartialEq))]
pub enum HttpMethod {
GET = 0b0000_0000_0000_0001,
POST = 0b0000_0000_0000_0010,
@ -14,9 +20,10 @@ pub enum HttpMethod {
CONNECT = 0b0000_0001_0000_0000,
}
#[cfg(feature = "user")]
impl HttpMethod {
pub fn convert_from_ebpf(ebpf_http_methods: EbpfHttpMethod) -> Vec<HttpMethod> {
let value = ebpf_http_methods as u16;
pub fn convert_from_bitmap(http_method_bitmap: HttpMethodBitmap) -> Vec<HttpMethod> {
let value = http_method_bitmap;
let mut http_methods = Vec::new();
let all_methods = [
@ -39,7 +46,7 @@ impl HttpMethod {
http_methods
}
pub fn convert_to_ebpf(http_methods: Vec<HttpMethod>) -> EbpfHttpMethod {
pub fn convert_to_bitmap(http_methods: Vec<HttpMethod>) -> HttpMethodBitmap {
let mut ebpf_http_method = 0_u16;
for http_method in http_methods {
ebpf_http_method |= http_method as u16;

View File

@ -0,0 +1,139 @@
use core::convert::TryFrom;
#[cfg(feature = "user")]
use aya::Pod;
pub type IPv4 = u32;
pub type IPv6 = u128;
pub type Port = u16;
#[repr(u8)]
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub enum IpVersion {
V4 = 4,
V6 = 6,
}
impl IpVersion {
#[inline(always)]
pub const fn as_u8(self) -> u8 {
self as u8
}
#[inline(always)]
pub const fn from_u8(value: u8) -> Option<Self> {
match value {
4 => Some(Self::V4),
6 => Some(Self::V6),
_ => None,
}
}
#[inline(always)]
pub const fn is_v4(self) -> bool {
matches!(self, Self::V4)
}
#[inline(always)]
pub const fn is_v6(self) -> bool {
matches!(self, Self::V6)
}
}
impl From<IpVersion> for u8 {
#[inline(always)]
fn from(value: IpVersion) -> Self {
value.as_u8()
}
}
impl TryFrom<u8> for IpVersion {
type Error = ();
#[inline(always)]
fn try_from(value: u8) -> Result<Self, Self::Error> {
Self::from_u8(value).ok_or(())
}
}
#[cfg(feature = "user")]
impl serde::Serialize for IpVersion {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_u8(self.as_u8())
}
}
#[repr(transparent)]
#[derive(Debug, Copy, Clone)]
pub struct AddrPortV4([u8; 8]);
impl AddrPortV4 {
#[inline(always)]
pub fn new(ip: u32, port: u16) -> Self {
let mut key = [0u8; 8];
key[0..4].copy_from_slice(&ip.to_ne_bytes());
key[4..6].copy_from_slice(&port.to_ne_bytes());
Self(key)
}
#[inline(always)]
pub fn as_bytes(&self) -> &[u8; 8] {
&self.0
}
#[inline(always)]
pub fn ip(&self) -> IPv4 {
let mut ip_bytes = [0u8; 4];
ip_bytes.copy_from_slice(&self.0[0..4]);
u32::from_ne_bytes(ip_bytes)
}
#[inline(always)]
pub fn port(&self) -> Port {
let mut port_bytes = [0u8; 2];
port_bytes.copy_from_slice(&self.0[4..6]);
u16::from_ne_bytes(port_bytes)
}
}
#[cfg(feature = "user")]
unsafe impl Pod for AddrPortV4 {}
#[repr(transparent)]
#[derive(Debug, Copy, Clone)]
pub struct AddrPortV6([u8; 32]);
impl AddrPortV6 {
#[inline(always)]
pub fn new(ip: u128, port: u16) -> Self {
let mut key = [0u8; 32];
key[0..16].copy_from_slice(&ip.to_ne_bytes());
key[16..18].copy_from_slice(&port.to_ne_bytes());
Self(key)
}
#[inline(always)]
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
#[inline(always)]
pub fn ip(&self) -> IPv6 {
let mut ip_bytes = [0u8; 16];
ip_bytes.copy_from_slice(&self.0[0..16]);
u128::from_ne_bytes(ip_bytes)
}
#[inline(always)]
pub fn port(&self) -> Port {
let mut port_bytes = [0u8; 2];
port_bytes.copy_from_slice(&self.0[16..18]);
u16::from_ne_bytes(port_bytes)
}
}
#[cfg(feature = "user")]
unsafe impl Pod for AddrPortV6 {}

View File

@ -0,0 +1,10 @@
pub mod dns_name;
pub mod drop_event;
pub mod empty;
pub mod flow_stats;
pub mod http_method;
pub mod ip_address;
pub mod parsed_packet;
pub mod port_rule;
pub mod pseudo_header;
pub mod rate_limit;

View File

@ -0,0 +1,58 @@
use crate::model::ip_address::{AddrPortV4, AddrPortV6};
#[repr(C, align(8))]
pub struct ParsedPacket {
pub timestamp_ns: u64,
pub src_ip: [u8; 16],
pub dst_ip: [u8; 16],
pub packet_length: u32,
pub payload_length: u32,
pub src_port: u16,
pub dst_port: u16,
pub ip_version: u8,
pub protocol: u8,
pub tcp_flags: u8,
pub _pad: u8,
}
impl ParsedPacket {
#[inline(always)]
pub fn src_ip_v4(&self) -> u32 {
u32::from_ne_bytes([self.src_ip[0], self.src_ip[1], self.src_ip[2], self.src_ip[3]])
}
#[inline(always)]
pub fn dst_ip_v4(&self) -> u32 {
u32::from_ne_bytes([self.dst_ip[0], self.dst_ip[1], self.dst_ip[2], self.dst_ip[3]])
}
#[inline(always)]
pub fn src_ip_v6(&self) -> u128 {
u128::from_ne_bytes(self.src_ip)
}
#[inline(always)]
pub fn dst_ip_v6(&self) -> u128 {
u128::from_ne_bytes(self.dst_ip)
}
#[inline(always)]
pub fn src_addr_v4(&self) -> AddrPortV4 {
AddrPortV4::new(self.src_ip_v4(), self.src_port)
}
#[inline(always)]
pub fn dst_addr_v4(&self) -> AddrPortV4 {
AddrPortV4::new(self.dst_ip_v4(), self.dst_port)
}
#[inline(always)]
pub fn src_addr_v6(&self) -> AddrPortV6 {
AddrPortV6::new(self.src_ip_v6(), self.src_port)
}
#[inline(always)]
pub fn dst_addr_v6(&self) -> AddrPortV6 {
AddrPortV6::new(self.dst_ip_v6(), self.dst_port)
}
}

View File

@ -0,0 +1,111 @@
#[cfg(feature = "user")]
use std::vec::Vec;
#[cfg(feature = "user")]
use aya::Pod;
use crate::define::setting::MAX_RULES_PORT;
use crate::model::ip_address::Port;
pub const PORT_RULE_MATCH_ALL: u8 = 1;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PortRule {
pub match_all: u8,
pub count: u8,
pub _pad: [u8; 2],
pub ports: [Port; MAX_RULES_PORT],
}
impl PortRule {
pub fn new_empty() -> Self {
Self {
match_all: 0,
count: 0,
_pad: [0; 2],
ports: [0; MAX_RULES_PORT],
}
}
pub fn new_match_all() -> Self {
Self {
match_all: PORT_RULE_MATCH_ALL,
count: 0,
_pad: [0; 2],
ports: [0; MAX_RULES_PORT],
}
}
pub fn is_match_all(&self) -> bool {
self.match_all == PORT_RULE_MATCH_ALL
}
pub fn contains(&self, port: Port) -> bool {
if self.is_match_all() {
return true;
}
for i in 0..(self.count as usize) {
if i >= MAX_RULES_PORT {
break;
}
if self.ports[i] == port {
return true;
}
}
false
}
#[cfg(feature = "user")]
pub fn add_port(&mut self, port: Port) -> bool {
if self.is_match_all() {
return true;
}
for i in 0..(self.count as usize) {
if i >= MAX_RULES_PORT {
return false;
}
if self.ports[i] == port {
return true;
}
}
if (self.count as usize) >= MAX_RULES_PORT {
return false;
}
self.ports[self.count as usize] = port;
self.count += 1;
true
}
#[cfg(feature = "user")]
pub fn remove_port(&mut self, port: Port) -> bool {
for i in 0..(self.count as usize) {
if i >= MAX_RULES_PORT {
break;
}
if self.ports[i] == port {
for j in i..(self.count as usize - 1) {
self.ports[j] = self.ports[j + 1];
}
self.count -= 1;
self.ports[self.count as usize] = 0;
return true;
}
}
false
}
#[cfg(feature = "user")]
pub fn to_port_vec(&self) -> Vec<Port> {
let count = (self.count as usize).min(MAX_RULES_PORT);
self.ports[..count].to_vec()
}
#[cfg(feature = "user")]
pub fn is_empty(&self) -> bool {
!self.is_match_all() && self.count == 0
}
}
#[cfg(feature = "user")]
unsafe impl Pod for PortRule {}

View File

@ -1,19 +1,19 @@
#[repr(C)]
#[derive(Clone, Copy)]
pub struct IPv4PseudoHeader {
pub source_ip: u32,
pub destination_ip: u32,
pub zeros: u8,
pub protocol: u8,
pub length: u16,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct IPv6PseudoHeader {
pub source_ip: u128,
pub destination_ip: u128,
pub length: u16,
pub zeros: u8,
pub next_header: u8,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct IPv4PseudoHeader {
pub source_ip: u32,
pub destination_ip: u32,
pub zeros: u8,
pub protocol: u8,
pub length: u16,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct IPv6PseudoHeader {
pub source_ip: u128,
pub destination_ip: u128,
pub length: u16,
pub zeros: u8,
pub next_header: u8,
}

View File

@ -0,0 +1,12 @@
#[cfg(feature = "user")]
use aya::Pod;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct RateState {
pub count: u64,
pub window_start: u64,
}
#[cfg(feature = "user")]
unsafe impl Pod for RateState {}

View File

@ -0,0 +1,15 @@
[package]
name = "net-guardia-cli"
version = "1.0.0"
edition = "2024"
[dependencies]
reqwest = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
clap = { workspace = true }
libc = { workspace = true }
[[bin]]
name = "net-guardia-cli"
path = "src/main.rs"

398
net-guardia-cli/src/main.rs Normal file
View File

@ -0,0 +1,398 @@
use std::io::{self, Write};
use std::mem;
use std::os::unix::io::AsRawFd;
use std::path::PathBuf;
use std::process;
use std::time::Duration;
use std::{env, fs};
use clap::{Parser, Subcommand};
use reqwest::{Client, Method};
use serde_json::Value;
const CSRF_HEADER: &str = "X-CSRF-Token";
#[derive(Parser)]
#[command(name = "net-guardia-cli", about = "NetGuardia CLI management tool", version)]
struct Cli {
#[arg(long, default_value = "http://127.0.0.1:8080", global = true, help = "API base URL")]
url: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(about = "System health + enforce mode")]
Status,
#[command(about = "ML engine status")]
Ml,
#[command(about = "Add IP to source blacklist")]
Block { ip: String },
#[command(about = "Remove IP from source blacklist")]
Unblock { ip: String },
#[command(about = "List ACL rules")]
Rules {
#[arg(long, default_value = "source")]
direction: String,
#[arg(long, default_value = "blacklist")]
list_type: String,
},
#[command(about = "Generate security report")]
Report,
#[command(about = "Get or set enforce mode")]
Mode {
#[arg(help = "Set mode to monitor or enforce")]
mode: Option<String>,
},
#[command(about = "Authenticate and save JWT")]
Login,
#[command(about = "List SOAR active blocks")]
Blocks,
#[command(about = "List SOAR playbooks")]
Playbooks,
#[command(about = "List SOAR execution history")]
Executions,
#[command(about = "API key management")]
ApiKey {
#[command(subcommand)]
action: ApiKeyAction,
},
}
#[derive(Subcommand)]
enum ApiKeyAction {
#[command(about = "Generate a new API key")]
Generate {
#[arg(long, default_value = "default")]
name: String,
#[arg(long, default_value = "read_only")]
level: String,
},
#[command(about = "List all API keys")]
List,
#[command(about = "Revoke an API key")]
Revoke { id: i64 },
}
struct ApiClient {
client: Client,
base_url: String,
token_path: PathBuf,
}
impl ApiClient {
fn new(base_url: String) -> Result<Self, String> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let token_path = dirs_next().join("token");
Ok(Self {
client,
base_url,
token_path,
})
}
fn load_token(&self) -> Option<String> {
fs::read_to_string(&self.token_path).ok()
}
fn save_token(&self, token: &str) -> Result<(), String> {
if let Some(parent) = self.token_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create token directory: {}", e))?;
}
fs::write(&self.token_path, token).map_err(|e| format!("Failed to save token: {}", e))
}
async fn get(&self, path: &str) -> Result<Value, String> {
let url = format!("{}{}", self.base_url, path);
let mut req = self.client.get(&url);
if let Some(token) = self.load_token() {
req = req.header("Authorization", format!("Bearer {}", token.trim()));
}
let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?;
let status = resp.status().as_u16();
if status == 401 {
return Err("Session expired. Run `net-guardia-cli login` to re-authenticate.".into());
}
let text = resp.text().await.map_err(|e| format!("Read error: {}", e))?;
serde_json::from_str(&text).map_err(|_| {
format!(
"Unexpected response (HTTP {}): {}",
status,
&text[..text.len().min(200)]
)
})
}
async fn request(&self, method: Method, path: &str, body: Option<Value>) -> Result<Value, String> {
let url = format!("{}{}", self.base_url, path);
let include_csrf = should_send_csrf(&method);
let mut req = self.client.request(method, &url);
if let Some(token) = self.load_token() {
req = req.header("Authorization", format!("Bearer {}", token.trim()));
}
if include_csrf {
req = req.header(CSRF_HEADER, "net-guardia-cli");
}
if let Some(b) = body {
req = req.json(&b);
}
let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?;
let status = resp.status().as_u16();
if status == 401 {
return Err("Session expired. Run `net-guardia-cli login` to re-authenticate.".into());
}
let text = resp.text().await.map_err(|e| format!("Read error: {}", e))?;
if text.is_empty() {
if (200..300).contains(&status) {
return Ok(Value::Null);
}
return Err(format!("Empty response (HTTP {})", status));
}
serde_json::from_str(&text).map_err(|_| {
format!(
"Unexpected response (HTTP {}): {}",
status,
&text[..text.len().min(200)]
)
})
}
async fn login(&self, username: &str, password: &str) -> Result<String, String> {
let url = format!("{}/api/auth/login", self.base_url);
let body = serde_json::json!({"username": username, "password": password});
let resp = self
.client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("Connection error: {}", e))?;
let data: Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?;
data.get("token")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
data.get("error")
.and_then(|e| e.as_str())
.unwrap_or("Login failed")
.to_string()
})
}
}
fn dirs_next() -> PathBuf {
let home = env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".net-guardia-cli")
}
fn print_json(data: &Value) {
println!("{}", serde_json::to_string_pretty(data).unwrap_or_default());
}
fn should_send_csrf(method: &Method) -> bool {
!matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
}
fn read_password() -> Result<String, String> {
let fd = io::stdin().as_raw_fd();
let mut termios = unsafe { mem::zeroed::<libc::termios>() };
unsafe { libc::tcgetattr(fd, &mut termios) };
let old = termios;
termios.c_lflag &= !libc::ECHO;
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) };
let mut password = String::new();
let read_result = io::stdin()
.read_line(&mut password)
.map_err(|e| format!("Failed to read password: {}", e));
println!();
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &old) };
read_result.map(|_| password.trim().to_string())
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let api = ApiClient::new(cli.url).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
process::exit(1);
});
let result = match cli.command {
Commands::Status => api.get("/api/health/status").await.map(|d| print_json(&d)),
Commands::Ml => api.get("/api/ml/status").await.map(|d| print_json(&d)),
Commands::Block { ip } => {
let is_v6 = ip.contains(':');
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
let addr = if is_v6 {
format!("[{}]:0", ip)
} else {
format!("{}:0", ip)
};
api.request(
Method::PUT,
&format!("/api/acl/{}/source/blacklist", ip_ver),
Some(Value::String(addr)),
)
.await
.map(|_| println!("Blocked: {}", ip))
}
Commands::Unblock { ip } => {
let is_v6 = ip.contains(':');
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
let addr = if is_v6 {
format!("[{}]:0", ip)
} else {
format!("{}:0", ip)
};
api.request(
Method::DELETE,
&format!("/api/acl/{}/source/blacklist", ip_ver),
Some(Value::String(addr)),
)
.await
.map(|_| println!("Unblocked: {}", ip))
}
Commands::Rules { direction, list_type } => {
let v4 = api.get(&format!("/api/acl/ipv4/{}/{}", direction, list_type)).await;
let v6 = api.get(&format!("/api/acl/ipv6/{}/{}", direction, list_type)).await;
println!("=== IPv4 {} {} ===", direction, list_type);
match v4 {
Ok(d) => print_json(&d),
Err(e) => eprintln!("{}", e),
}
println!("\n=== IPv6 {} {} ===", direction, list_type);
match v6 {
Ok(d) => print_json(&d),
Err(e) => eprintln!("{}", e),
}
Ok(())
}
Commands::Report => api.get("/api/report/data").await.map(|d| print_json(&d)),
Commands::Mode { mode } => match mode {
Some(m) => {
let body = serde_json::json!({"mode": m});
api.request(Method::PUT, "/api/system/enforce-mode", Some(body))
.await
.map(|d| print_json(&d))
}
None => api.get("/api/system/enforce-mode").await.map(|d| print_json(&d)),
},
Commands::Login => {
print!("Username: ");
let mut stdout = io::stdout();
if let Err(e) = stdout.flush() {
eprintln_and_exit(format!("Failed to flush stdout: {}", e));
}
let mut username = String::new();
if let Err(e) = io::stdin().read_line(&mut username) {
eprintln_and_exit(format!("Failed to read username: {}", e));
}
let username = username.trim();
print!("Password: ");
if let Err(e) = stdout.flush() {
eprintln_and_exit(format!("Failed to flush stdout: {}", e));
}
let password = match read_password() {
Ok(password) => password,
Err(e) => eprintln_and_exit(e),
};
match api.login(username, &password).await {
Ok(token) => match api.save_token(&token) {
Ok(()) => {
println!("Login successful. Token saved to ~/.net-guardia-cli/token");
Ok(())
}
Err(e) => Err(e),
},
Err(e) => Err(e),
}
}
Commands::Blocks => api.get("/api/soar/blocks").await.map(|d| print_json(&d)),
Commands::Playbooks => api.get("/api/soar/playbooks").await.map(|d| print_json(&d)),
Commands::Executions => api.get("/api/soar/executions").await.map(|d| print_json(&d)),
Commands::ApiKey { action } => match action {
ApiKeyAction::Generate { name, level } => {
let body = serde_json::json!({"name": name, "level": level});
api.request(Method::POST, "/api/api-keys/generate", Some(body))
.await
.map(|data| {
if let Some(key) = data.get("key").and_then(|k| k.as_str()) {
println!("Generated API key: {}", key);
println!("Name: {}, Level: {}", name, level);
println!("Set NETGUARDIA_API_KEY={} in your client config", key);
} else {
print_json(&data);
}
})
}
ApiKeyAction::List => api.get("/api/api-keys").await.map(|data| {
if let Some(keys) = data.as_array() {
if keys.is_empty() {
println!("No API keys found.");
} else {
println!("{:<6} {:<20} {:<15} {:<22} Last Used", "ID", "Name", "Level", "Created");
println!("{}", "-".repeat(80));
for key in keys {
println!(
"{:<6} {:<20} {:<15} {:<22} {}",
key.get("id").and_then(|v| v.as_i64()).unwrap_or(0),
key.get("name").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("permission_level").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("created_at").and_then(|v| v.as_str()).unwrap_or("-"),
key.get("last_used_at").and_then(|v| v.as_str()).unwrap_or("never"),
);
}
}
} else {
print_json(&data);
}
}),
ApiKeyAction::Revoke { id } => api
.request(Method::DELETE, &format!("/api/api-keys/{}", id), None)
.await
.map(|data| {
if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) {
println!("Key #{} revoked successfully.", id);
} else {
print_json(&data);
}
}),
},
};
if let Err(e) = result {
eprintln!("Error: {}", e);
process::exit(1);
}
}
fn eprintln_and_exit(message: String) -> ! {
eprintln!("Error: {}", message);
process::exit(1);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn csrf_header_is_only_needed_for_state_changing_methods() {
assert!(!should_send_csrf(&Method::GET));
assert!(!should_send_csrf(&Method::HEAD));
assert!(!should_send_csrf(&Method::OPTIONS));
assert!(should_send_csrf(&Method::POST));
assert!(should_send_csrf(&Method::PUT));
assert!(should_send_csrf(&Method::DELETE));
}
}

View File

@ -1,21 +0,0 @@
#![no_std]
pub mod model;
/// Maximum number of statistics entries that can be stored
///
/// This constant defines the upper limit for statistical data entries
/// to prevent unbounded memory growth.
pub const MAX_STATS: u32 = 100000;
/// Maximum number of network filtering rules allowed
///
/// Limits the number of rules that can be configured to ensure
/// predictable performance and resource usage.
pub const MAX_RULES: u32 = 1000;
/// Maximum number of port-specific rules allowed
///
/// Defines the upper limit for port-based filtering rules to maintain
/// efficient rule processing.
pub const MAX_RULES_PORT: usize = 32;
pub const MAX_PORT_ACCESS: usize = 12;

View File

@ -1,112 +0,0 @@
use network_types::eth::EtherType;
use crate::model::ip_address::{EbpfAddrPortV4, EbpfAddrPortV6};
use network_types::ip::IpProto;
pub struct Event {
pub eth_type: EtherType,
pub protocol: IpProto,
pub source_ip: u128,
pub destination_ip: u128,
pub source_port: u16,
pub destination_port: u16,
pub len: u32,
pub timestamp: u64
}
impl Event {
#[inline(always)]
pub fn to_ipv4_event(&self) -> IPv4Event {
IPv4Event {
protocol: self.protocol,
source_ip: self.source_ip as u32,
destination_ip: self.destination_ip as u32,
source_port: self.source_port,
destination_port: self.destination_port,
len: self.len,
timestamp: self.timestamp,
}
}
#[inline(always)]
pub fn to_ipv6_event(&self) -> IPv6Event {
IPv6Event {
protocol: self.protocol,
source_ip: self.source_ip,
destination_ip: self.destination_ip,
source_port: self.source_port,
destination_port: self.destination_port,
len: self.len,
timestamp: self.timestamp,
}
}
#[inline(always)]
pub fn into_ipv4_event(self) -> IPv4Event {
IPv4Event {
protocol: self.protocol,
source_ip: self.source_ip as u32,
destination_ip: self.destination_ip as u32,
source_port: self.source_port,
destination_port: self.destination_port,
len: self.len,
timestamp: self.timestamp,
}
}
#[inline(always)]
pub fn into_ipv6_event(self) -> IPv6Event {
IPv6Event {
protocol: self.protocol,
source_ip: self.source_ip,
destination_ip: self.destination_ip,
source_port: self.source_port,
destination_port: self.destination_port,
len: self.len,
timestamp: self.timestamp,
}
}
}
pub struct IPv4Event {
pub protocol: IpProto,
pub source_ip: u32,
pub destination_ip: u32,
pub source_port: u16,
pub destination_port: u16,
pub len: u32,
pub timestamp: u64
}
impl IPv4Event {
#[inline(always)]
pub fn get_source(&self) -> EbpfAddrPortV4 {
[self.source_ip, self.source_port as u32]
}
#[inline(always)]
pub fn get_destination(&self) -> EbpfAddrPortV4 {
[self.destination_ip, self.destination_port as u32]
}
}
pub struct IPv6Event {
pub protocol: IpProto,
pub source_ip: u128,
pub destination_ip: u128,
pub source_port: u16,
pub destination_port: u16,
pub len: u32,
pub timestamp: u64
}
impl IPv6Event {
#[inline(always)]
pub fn get_source(&self) -> EbpfAddrPortV6 {
[self.source_ip, self.source_port as u128]
}
#[inline(always)]
pub fn get_destination(&self) -> EbpfAddrPortV6 {
[self.destination_ip, self.destination_port as u128]
}
}

Some files were not shown because too many files have changed in this diff Show More