The tool reads a raw ruleset file (iptables-save or nft list ruleset output), converts it into an internal representation, and then does one of several things with it depending on which subcommand you ran. Here is the full picture:
ββββββββββββββββ
β Raw Ruleset β
β (file) β
ββββββββ¬ββββββββ
β
ββββββββΌββββββββ
β detect_formatβ
ββββββββ¬ββββββββ
β
ββββββββββββββββΌβββββββββββββββ
β β
ββββββββΌββββββββ ββββββββΌββββββββ
βparse_iptablesβ βparse_nftablesβ
ββββββββ¬ββββββββ ββββββββ¬ββββββββ
β β
ββββββββββββββββ¬βββββββββββββββ
β
ββββββββΌββββββββ
β Ruleset β
β (internal) β
ββββββββ¬ββββββββ
β
βββββββββββ¬ββββββββββββΌββββββββββββ¬βββββββββββ
β β β β β
ββββββββΌβββ βββββΌβββββ βββββΌβββββ ββββββΌββββ βββββΌβββββ
β display β βanalyze β βoptimizeβ β export β β diff β
β table β βconflictβ βsuggest β βruleset β βcompare β
βββββββββββ βββββ¬βββββ βββββ¬βββββ ββββββ¬ββββ βββββ¬βββββ
β β β β
ββββββββΌββββββββββββΌβββ ββββββΌββββ ββββΌββββββ
β print_findings β β string β βprint_ β
βββββββββββββββββββββββ β output β β diff β
ββββββββββ ββββββββββ
Each CLI subcommand maps to a function in main.v that calls into one or more modules. No subcommand touches more than two or three modules. The harden command is the exception: it skips the parser entirely and goes straight to the generator.
| Command | Modules used |
|---|---|
load |
parser, display |
analyze |
parser, analyzer, display |
optimize |
parser, analyzer, display |
harden |
generator, display (banner only) |
export |
parser, generator |
diff |
parser, display |
In V, every directory under src/ is a module. The module name matches the directory name. Files inside a module share the same namespace automatically, so common.v, iptables.v, and nftables.v all belong to module parser and can call each other's functions directly.
src/
βββ main.v (module main)
βββ config/
β βββ config.v (module config)
βββ models/
β βββ models.v (module models)
βββ parser/
β βββ common.v (module parser)
β βββ iptables.v (module parser)
β βββ nftables.v (module parser)
βββ analyzer/
β βββ conflict.v (module analyzer)
β βββ optimizer.v (module analyzer)
βββ generator/
β βββ generator.v (module generator)
βββ display/
βββ display.v (module display)
The dependency graph:
ββββββββ
β main β
ββββ¬ββββ
β
ββββββββββ¬ββββββββΌβββββββββ¬βββββββββββ
β β β β β
βββββΌβββ ββββΌββββ βββΌβββ ββββΌβββββ βββββΌββββ
βparserβ βanalyz.β βgen.β βdisplayβ βconfig β
ββββ¬ββββ ββββ¬ββββ βββ¬βββ ββββ¬βββββ βββββββββ
β β β β
β ββββΌββββ β ββββΌββββ
ββββββΊβmodels βββββ βmodelsβ
β ββββ¬ββββ ββββ¬ββββ
β β β
βΌ βΌ βΌ
ββββββββ ββββββββ ββββββββ
βconfigβ βconfigβ βconfigβ
ββββββββ ββββββββ ββββββββ
Three things to notice:
configandmodelsare leaf modules. They import nothing from the project.parser,analyzer,generator, anddisplaynever import each other. Zero cross-dependencies.mainis the only module that imports everything. It is the composition root.
This means you can rewrite the entire nftables parser without touching the analyzer, or overhaul the display layer without the generator knowing. V enforces no circular imports at compile time, so this structure cannot accidentally degrade.
fwrule load testdata/iptables_basic.rules
os.read_file(path)
β
βΌ
detect_format(content) βββΊ RuleSource.iptables
β
βΌ
parse_iptables(content)
β
βββ iterate lines
βββ "*filter" β set current_table = .filter
βββ ":INPUT DROP" β policies["INPUT"] = .drop
βββ "-A INPUT ..." β tokenize β parse flags β Rule
β
βΌ
Ruleset { rules: [...], policies: {...}, source: .iptables }
β
βββ display.print_banner()
βββ display.print_summary(rs)
βββ display.print_rule_table(rs)
load_ruleset in main.v reads the file, auto-detects the format, and dispatches to the right parser. The resulting Ruleset goes to the display module for rendering.
fwrule analyze testdata/iptables_conflicts.rules
load_ruleset(path) βββΊ Ruleset
β
βββ analyzer.analyze_conflicts(rs)
β β
β βββ rs.rules_by_chain() β map[string][]int
β β
β βββ for each chain:
β find_duplicates(rules, indices)
β find_shadowed_rules(rules, indices)
β find_contradictions(rules, indices)
β find_redundant_rules(rules, indices)
β β
β ββββΊ []Finding
β
βββ analyzer.suggest_optimizations(rs)
β β
β βββ for each chain:
β find_mergeable_ports(rules, indices)
β suggest_reordering(rules, indices)
β find_missing_rate_limits(rules, indices)
β find_missing_conntrack(rules, indices)
β β
β βββ find_missing_logging(rs) (whole-ruleset check)
β β
β ββββΊ []Finding
β
βββ display.print_findings(conflicts)
display.print_findings(optimizations)
Two passes: conflict detection (things that are broken) then optimization analysis (things that could be better). Both return []Finding that the display module renders with severity coloring.
fwrule harden -s ssh,http,https -f nftables
flag.new_flag_parser(args)
β
βββ services = ["ssh", "http", "https"]
βββ iface = "eth0", format = nftables
β
βΌ
generator.generate_hardened(services, iface, .nftables)
β
βββ default-deny policy β loopback accept β conntrack
βββ anti-spoofing (RFC 1918 on public iface)
βββ ICMP rate-limited
βββ per-service rules from config.service_ports
βββ drop logging β final drop
β
ββββΊ string (printed to stdout)
This is the only command that does not parse a file. It builds a ruleset from scratch using templates and the service-to-port mapping from config.v.
fwrule export testdata/iptables_basic.rules -f nftables
load_ruleset(path) βββΊ Ruleset (source: .iptables)
β
βΌ
generator.export_ruleset(rs, .nftables)
β
βββ group rules by table
βββ for each chain: header + policy, then rule_to_nftables per rule
β
ββββΊ string (printed to stdout)
Each Rule struct carries enough information to be serialized into either format. The rule_to_iptables and rule_to_nftables functions read fields off the struct and reconstruct the target syntax.
fwrule diff old.rules new.rules
load_ruleset(path1) βββΊ Ruleset (left)
load_ruleset(path2) βββΊ Ruleset (right)
β
βΌ
display.print_diff(left, right)
β
βββ build_rule_set(rules) β map[string]bool (both sides)
βββ keys in left but not right β "only in left"
βββ keys in right but not left β "only in right"
βββ no differences β "Rulesets are equivalent"
The diff normalizes every rule to a canonical string via Rule.str() and compares sets. It compares semantic content, not raw text, so an iptables rule and an nftables rule expressing the same policy show as equivalent.
All types live in src/models/models.v. The parser produces them, the analyzer inspects them, the generator and display modules consume them.
Ruleset {
rules []Rule ordered list of all parsed rules
policies map[string]Action chain name β default action ("INPUT" β .drop)
source RuleSource iptables or nftables
}
The top-level container. rules is ordered by position in the original file. policies maps chain names to their default actions. The rules_by_chain() method groups rule indices by chain name so the analyzer can restrict comparisons to within a single chain.
Rule {
table Table filter, nat, mangle, raw, security
chain string "INPUT", "FORWARD", or custom name
chain_type ChainType parsed enum for known chains
action Action accept, drop, reject, log, masquerade, ...
criteria MatchCriteria all match conditions (see below)
target_args string extra args after -j (e.g., --log-prefix "...")
line_number int original line number in source file
raw_text string unparsed original line
source RuleSource which format this rule came from
}
Whether the input was iptables or nftables, every parsed rule becomes this same struct. chain_type defaults to .custom for user-defined chains. line_number and raw_text survive the parse so that findings can reference back to the original file.
MatchCriteria {
protocol Protocol default: .all (matches everything)
source ?NetworkAddr optional source CIDR
destination ?NetworkAddr optional destination CIDR
src_ports []PortSpec source port ranges
dst_ports []PortSpec destination port ranges
in_iface ?string input interface
out_iface ?string output interface
states ConnState bitmask: new|established|related|invalid
icmp_type ?string ICMP type string
limit_rate ?string rate limit (e.g., "3/minute")
limit_burst ?int burst count
comment ?string rule comment
}
This is where V's option types (?Type) pay off. source ?NetworkAddr means "this rule might or might not constrain the source address." When none, the rule matches any source. When set, it matches only that network. This distinction is critical for superset/subset logic: source = none is a superset of source = 10.0.0.0/8, because "match anything" contains "match this network." Without option types you could not distinguish "no constraint" from "explicitly matches 0.0.0.0/0."
Finding {
severity Severity info, warning, critical
title string short label ("Shadowed rule detected")
description string full explanation with rule numbers
rule_indices []int zero-based indices into Ruleset.rules
suggestion string actionable fix
}
The output of both conflict detection and optimization analysis. rule_indices contains zero-based indices into Ruleset.rules, so the display layer can say "Rules 7, 8" without needing to hold rule objects.
NetworkAddr { PortSpec {
address string start int
cidr int = 32 end int = -1
negated bool negated bool
} }
NetworkAddr stores an IP and prefix length. The cidr field defaults to 32 (a single host). The negated flag handles ! prefixes in both iptables (! -s 10.0.0.0/8) and nftables (ip saddr != 10.0.0.0/8).
PortSpec stores a port or port range. A single port like 22 has end = -1, and effective_end() returns start in that case so range math works uniformly. A range like 1024:65535 has start = 1024, end = 65535.
cidr_contains and port_range_contains are the two containment primitives that the analyzer's entire superset/subset logic is built on.
@[flag]
pub enum ConnState {
new_conn bit 0 β value 1
established bit 1 β value 2
related bit 2 β value 4
invalid bit 3 β value 8
untracked bit 4 β value 16
}
The @[flag] attribute makes this a bitfield. Each variant is a power of two, and a single ConnState value can represent multiple states at once. ESTABLISHED,RELATED is two bits set in one integer. The set(), has(), all(), and is_empty() methods are generated automatically by V.
This mirrors how the kernel's conntrack system actually works: connection states are bitmask flags, not mutually exclusive values. A packet in state ESTABLISHED is not also NEW, but a rule can match both ESTABLISHED and RELATED simultaneously. The bitfield makes subset checks in the analyzer trivial: outer.states.all(inner.states) is a single bitwise AND.
The parser solves a two-format problem. iptables-save and nft list ruleset express the same firewall concepts but with completely different syntax.
iptables-save output is line-oriented. Every rule is one line with flag-value pairs:
-A INPUT -p tcp -s 10.0.0.0/8 --dport 22 -m conntrack --ctstate NEW -j ACCEPT
The parser works in two stages. tokenize_iptables splits on whitespace while respecting quoted strings, then the token iterator consumes flag-value pairs:
["-A", "INPUT", "-p", "tcp", "-s", "10.0.0.0/8", "--dport", "22", "-j", "ACCEPT"]
β β β β β β β β β β
ββchainββ βprotoββ βββsourceβββ βββportββββ βactionββ
The ! negation operator is handled by a next_negated flag that carries forward to the next address or port parsed. At the file level, parse_iptables iterates all lines: *filter sets the current table, :INPUT DROP [0:0] records chain policies, COMMIT is skipped, and lines starting with -A/-I get fed to the rule parser.
nftables output is block-structured with braces:
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
tcp dport 22 accept
}
}
The parser uses line-by-line iteration with three levels of nesting:
parse_nftables scans for "table" lines
β
βββ parse_nft_table extracts table name, scans for "chain" lines
β
βββ parse_nft_chain extracts chain name + policy, scans for rule lines
β
βββ parse_nft_rule tokenizes a single rule line
Each function takes the full lines []string array and a start index, returning the new index after consuming its block. A closing } returns control to the parent.
Inside each rule line, keyword tokens drive the parse: tcp/udp set protocol and trigger port parsing, ip saddr/daddr extract addresses, ct state extracts connection tracking, and terminal keywords (accept, drop, reject) set the action. parse_nft_port_match handles both single ports (dport 22) and brace-enclosed sets (dport { 80, 443 }).
Both parsers share functions from common.v: parse_network_addr (CIDR + negation), parse_port_spec (single ports, ranges, negation), parse_port_list (comma-separated), parse_protocol (names and numbers to enum), parse_action, parse_table, parse_chain_type, and parse_conn_states (comma-separated states to bitfield).
detect_format looks at the first non-empty, non-comment line. * or : or -A means iptables. table means nftables.
The analyzer groups rules by chain via rs.rules_by_chain(), then compares every pair within each chain (N*(N-1)/2 comparisons per chain). Rules in different chains are never compared because the kernel evaluates each chain independently.
ββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Type β How it is detected β
ββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Shadowed β Rule A appears before rule B in the chain. A's β
β β criteria is a superset of B's. B can never fire β
β β because A catches all its traffic first. β
ββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Contradiction β Rules A and B overlap in their match criteria but β
β β have opposing actions (one accepts, one drops or β
β β rejects). Not a full superset, or it would be β
β β classified as shadowing instead. β
ββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Duplicate β Two rules have identical criteria AND the same β
β β action. The second one is dead weight. β
ββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Redundant β Rule A is a superset of rule B with the same β
β β action, but they are not exact duplicates. B is β
β β unnecessary but not harmful. β
ββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
"Does rule A match every packet that rule B matches?" breaks down field by field.
CIDR containment converts IPs to 32-bit integers and compares prefixes via bit shifts:
outer = 10.0.0.0/8 inner = 10.1.2.0/24
ip_to_u32("10.0.0.0") = 0x0A000000
ip_to_u32("10.1.2.0") = 0x0A010200
shift = 32 - 8 = 24
0x0A000000 >> 24 = 0x0A
0x0A010200 >> 24 = 0x0A
Same prefix after shift β 10.0.0.0/8 contains 10.1.2.0/24
Port range containment is a simple bounds check:
outer = 1024:65535 inner = 8080:8443
outer.start (1024) <= inner.start (8080) β
outer.end (65535) >= inner.end (8443) β
β outer contains inner
Protocol hierarchy: protocol .all is a superset of every specific protocol. If the outer rule matches .all and the inner matches .tcp, the outer covers everything the inner does.
Option type handling: none (no constraint) is a superset of any specific value. source = none covers source = 10.0.0.0/8 because "match anything" contains "match this network." If the outer has a specific address, the inner must also have one, and CIDR containment must hold.
Every Finding includes rule_indices pointing back to specific positions in Ruleset.rules. The display layer uses these to print "Rules: 7, 12" next to each finding without needing the Rule objects themselves.
generate_hardened dispatches to either generate_iptables_hardened or generate_nftables_hardened. Both build a string array line by line following the same logical template: default-deny policies, loopback accept, conntrack, anti-spoofing (RFC 1918 on public interface), rate-limited ICMP, per-service rules from config.service_ports, drop logging, and a final explicit DROP. SSH gets rate limiting (3/minute burst 5). DNS gets both TCP and UDP. NTP gets UDP only. Everything else gets TCP.
export_ruleset iterates every rule and calls rule_to_iptables or rule_to_nftables to reconstruct the syntax:
Rule { protocol: .tcp, dst_ports: [PortSpec{22}], action: .accept }
β
βββ rule_to_iptables β "-A INPUT -p tcp --dport 22 -j ACCEPT"
βββ rule_to_nftables β "tcp dport 22 accept"
The export functions also handle structural elements: table headers, chain declarations with policies, and format-specific markers like COMMIT for iptables.
V compiles to a native binary with zero runtime dependencies. You run v . and get a single executable. No interpreter, no VM, no shared libraries beyond libc. For a security tool that might run on locked-down servers, this matters. The v.mod file shows dependencies: [].
The syntax is deliberately simple. If you can read C, Go, or Python, you can read V immediately. Option types (?Type) give you null safety without Rust's ceremony. The @[flag] enum attribute gives you bitfield operations for free, mapping perfectly to how conntrack states work in the kernel.
This is O(n^2), and there are faster approaches (decision trees, BDDs, interval trees). But at 100 rules per chain, pairwise does 4,950 checks of integer comparisons, finishing in under a millisecond. Even 1000 rules (extreme) yields roughly 500,000 comparisons, still milliseconds.
More importantly, pairwise comparison produces findings referencing exactly two rules. "Rule 7 shadows rule 12" is immediately actionable. A BDD-based approach would need extra work to trace back to the specific rules involved.
The V standard library provides os (file I/O), flag (argument parsing), term (ANSI colors), and strings (manipulation). That covers everything needed. External dependencies in security tools create supply chain risk. A single static binary can be dropped onto any Linux system and run immediately with no package manager involved.
You could put everything in one file. V would not care. But separate modules give you compiler-enforced boundaries (the parser cannot call display functions), independent test files (v test src/parser/ runs parser tests in isolation), and navigability (conflict detection bug means look at src/analyzer/conflict.v).
Adding a new parser (for ufw rules, say) would require a new file in src/parser/, a new RuleSource variant, a new case in detect_format, and a new case in load_ruleset. No changes needed in analyzer, generator, or display. They already operate on the Ruleset abstraction.