feat: complete project firewall-rule-engine

This commit is contained in:
CarterPerez-dev 2026-03-26 00:41:59 -04:00
parent 64862125a2
commit 22bb337320
31 changed files with 7573 additions and 1 deletions

View File

@ -0,0 +1,26 @@
# ©AngelaMos | 2026
# .gitignore
# Build
bin/
*.o
*.so
*.out
*.tmp.c
# V internals
.cache/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Development reference (local only)
vlang-reference/

View File

@ -0,0 +1,85 @@
# ©AngelaMos | 2026
# Justfile
set shell := ["bash", "-uc"]
project := "fwrule"
version := "1.0.0"
src := "src/"
bin := "bin/" + project
default:
@just --list --unsorted
[group('build')]
build:
v -o {{bin}} {{src}}
[group('build')]
release:
v -prod -o {{bin}} {{src}}
@ls -lh {{bin}}
[group('test')]
test:
v test {{src}}
[group('test')]
test-verbose:
v -stats test {{src}}
[group('dev')]
fmt:
v fmt -w {{src}}
[group('dev')]
fmt-check:
v fmt -verify {{src}}
[group('dev')]
run *ARGS:
v -o {{bin}} {{src}} && ./{{bin}} {{ARGS}}
[group('dev')]
load FILE:
@just run load {{FILE}}
[group('dev')]
analyze FILE:
@just run analyze {{FILE}}
[group('dev')]
harden *ARGS:
@just run harden {{ARGS}}
[group('dev')]
export FILE *ARGS:
@just run export {{FILE}} {{ARGS}}
[group('dev')]
diff F1 F2:
@just run diff {{F1}} {{F2}}
[group('util')]
clean:
rm -rf bin/
find . -name '*.tmp.c' -delete
[group('util')]
info:
@echo "{{project}} v{{version}}"
@echo "V compiler: $(v version)"
@echo "Platform: $(uname -s)/$(uname -m)"
[group('util')]
smoke: build
./{{bin}} version
./{{bin}} help
./{{bin}} load testdata/iptables_basic.rules
./{{bin}} load testdata/nftables_basic.rules
./{{bin}} analyze testdata/iptables_conflicts.rules
./{{bin}} harden -s ssh,http,https -f iptables
./{{bin}} harden -s ssh,http,https -f nftables
./{{bin}} export testdata/iptables_basic.rules -f nftables
./{{bin}} diff testdata/iptables_basic.rules testdata/nftables_basic.rules
@echo "All smoke tests passed"

View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -0,0 +1,87 @@
```toml
██╗ ██╗███████╗██╗ ██╗██████╗ ██╗ ██╗██╗ ███████╗
██║ ██║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔════╝
██║ ██║█████╗ ██║ █╗ ██║██████╔╝██║ ██║██║ █████╗
╚██╗ ██╔╝██╔══╝ ██║███╗██║██╔══██╗██║ ██║██║ ██╔══╝
╚████╔╝ ██║ ╚███╔███╔╝██║ ██║╚██████╔╝███████╗███████╗
╚═══╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝
```
[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2311-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/firewall-rule-engine)
[![V](https://img.shields.io/badge/V-0.5.1-5D87BF?style=flat&logo=v&logoColor=white)](https://vlang.io)
[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
> Firewall rule parser, conflict detector, optimizer, and hardened ruleset generator for iptables and nftables.
*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
## What It Does
- Parse iptables-save and nft list ruleset formats into a unified rule model
- Detect shadowed rules, contradictions, duplicates, and redundant entries
- Suggest optimizations: port merging, rule reordering, missing rate limits, missing conntrack
- Generate hardened rulesets with default-deny, anti-spoofing, ICMP rate limiting, and connection tracking
- Export rulesets between iptables and nftables formats
- Diff two rulesets to find what changed
- Colored terminal output with severity-coded findings
## Quick Start
```bash
./install.sh
fwrule analyze /etc/iptables.rules
```
> [!TIP]
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands.
>
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
## Commands
| Command | Description |
|---------|-------------|
| `fwrule load <file>` | Parse and display a ruleset in table format |
| `fwrule analyze <file>` | Run conflict detection and optimization analysis |
| `fwrule optimize <file>` | Show optimization suggestions only |
| `fwrule harden [options]` | Generate a hardened ruleset from scratch |
| `fwrule export <file> -f <fmt>` | Convert between iptables and nftables formats |
| `fwrule diff <file1> <file2>` | Compare two rulesets side by side |
### Harden Options
| Flag | Default | Description |
|------|---------|-------------|
| `-s, --services` | `ssh,http,https` | Comma-separated services to allow |
| `-i, --iface` | `eth0` | Public-facing network interface |
| `-f, --format` | `iptables` | Output format: `iptables` or `nftables` |
## Examples
```bash
fwrule load testdata/iptables_basic.rules
fwrule analyze testdata/iptables_conflicts.rules
fwrule harden -s ssh,http,https,dns -f nftables
fwrule export testdata/iptables_basic.rules -f nftables
fwrule diff testdata/iptables_basic.rules testdata/nftables_basic.rules
```
## Learn
This project includes step-by-step learning materials covering security theory, architecture, and implementation.
| Module | Topic |
|--------|-------|
| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start |
| [01 - Concepts](learn/01-CONCEPTS.md) | Firewall theory, netfilter, and real-world breaches |
| [02 - Architecture](learn/02-ARCHITECTURE.md) | System design, module layout, and data flow |
| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough with file references |
| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas and exercises |
## License
AGPL 3.0

View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
# ©AngelaMos | 2026
# install.sh
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e "${CYAN}[INFO]${NC} $1"; }
success() { echo -e "${GREEN}[OK]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
fail() { echo -e "${RED}[FAIL]${NC} $1"; exit 1; }
PROJECT="fwrule"
INSTALL_DIR="${HOME}/.local/bin"
echo -e "${BOLD}${CYAN}"
cat << 'BANNER'
┌─────────────────────────────────────────┐
│ FWRULE Installer │
│ Firewall Rule Engine for iptables/nft │
└─────────────────────────────────────────┘
BANNER
echo -e "${NC}"
check_v() {
if command -v v &> /dev/null; then
V_VERSION=$(v version 2>/dev/null || echo "unknown")
success "V compiler found: ${V_VERSION}"
return 0
fi
return 1
}
install_v() {
info "V compiler not found. Installing from source..."
if ! command -v git &> /dev/null; then
fail "git is required to install V. Please install git first."
fi
if ! command -v make &> /dev/null; then
fail "make is required to install V. Please install build tools first."
fi
V_DIR="${HOME}/.local/share/vlang"
if [[ -d "${V_DIR}" ]]; then
info "Updating existing V installation..."
cd "${V_DIR}"
git pull --quiet
else
info "Cloning V repository..."
git clone --depth 1 https://github.com/vlang/v "${V_DIR}"
cd "${V_DIR}"
fi
info "Building V compiler..."
make --quiet
mkdir -p "${INSTALL_DIR}"
ln -sf "${V_DIR}/v" "${INSTALL_DIR}/v"
success "V compiler installed to ${INSTALL_DIR}/v"
if ! echo "$PATH" | grep -q "${INSTALL_DIR}"; then
warn "${INSTALL_DIR} is not in your PATH"
SHELL_NAME=$(basename "${SHELL:-bash}")
case "${SHELL_NAME}" in
zsh) RC_FILE="${HOME}/.zshrc" ;;
fish) RC_FILE="${HOME}/.config/fish/config.fish" ;;
*) RC_FILE="${HOME}/.bashrc" ;;
esac
if [[ "${SHELL_NAME}" == "fish" ]]; then
PATH_LINE="fish_add_path ${INSTALL_DIR}"
else
PATH_LINE="export PATH=\"${INSTALL_DIR}:\$PATH\""
fi
if [[ -f "${RC_FILE}" ]] && grep -q "${INSTALL_DIR}" "${RC_FILE}" 2>/dev/null; then
info "PATH entry already in ${RC_FILE}"
else
echo "${PATH_LINE}" >> "${RC_FILE}"
success "Added ${INSTALL_DIR} to PATH in ${RC_FILE}"
warn "Run 'source ${RC_FILE}' or restart your shell"
fi
export PATH="${INSTALL_DIR}:${PATH}"
fi
}
build_project() {
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}"
info "Building ${PROJECT}..."
mkdir -p bin/
v -prod -o "bin/${PROJECT}" src/
success "Built bin/${PROJECT}"
BINARY_SIZE=$(ls -lh "bin/${PROJECT}" | awk '{print $5}')
info "Binary size: ${BINARY_SIZE}"
}
install_binary() {
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p "${INSTALL_DIR}"
cp "${SCRIPT_DIR}/bin/${PROJECT}" "${INSTALL_DIR}/${PROJECT}"
chmod +x "${INSTALL_DIR}/${PROJECT}"
success "Installed ${PROJECT} to ${INSTALL_DIR}/${PROJECT}"
}
verify_install() {
if command -v "${PROJECT}" &> /dev/null; then
VERSION=$("${PROJECT}" version 2>/dev/null || echo "unknown")
success "Verification passed: ${VERSION}"
else
warn "Binary installed but not found in PATH"
info "Run: ${INSTALL_DIR}/${PROJECT} version"
fi
}
if ! check_v; then
install_v
fi
build_project
install_binary
verify_install
echo ""
echo -e "${GREEN}${BOLD}Installation complete!${NC}"
echo ""
echo -e "${BOLD}Usage:${NC}"
echo " ${PROJECT} load rules.txt"
echo " ${PROJECT} analyze /etc/iptables.rules"
echo " ${PROJECT} harden -s ssh,http,https -f nftables"
echo " ${PROJECT} export rules.txt -f nftables"
echo " ${PROJECT} diff old.rules new.rules"
echo ""
echo -e "${BOLD}Run tests:${NC}"
echo " just test"
echo ""

View File

@ -0,0 +1,161 @@
<!-- © AngelaMos | 2026 | 00-OVERVIEW.md -->
# Firewall Rule Engine
## What This Is
A CLI tool called `fwrule` that parses iptables and nftables rulesets, detects conflicts between rules (shadowing, contradictions, duplicates), suggests performance and security optimizations, and generates hardened rulesets from scratch. It reads raw ruleset files, runs pairwise analysis across every rule in each chain, and outputs findings with severity ratings and fix suggestions. Written in V.
## Why This Matters
Firewall misconfigurations are behind a significant percentage of cloud breaches, and the problem is almost always the same: someone wrote rules that look correct on paper but interact in ways they did not expect.
The Capital One breach in 2019 (CVE-2019-5418) happened because a WAF was misconfigured, allowing an attacker to perform SSRF against the EC2 metadata service and exfiltrate 100 million customer records from S3. The firewall rules were too permissive on the WAF role, and nobody caught it. The company paid $80 million in fines and $190 million in settlements.
AWS publishes that security group misconfigurations are one of the top causes of S3 bucket exposures. The Imperva breach in 2019 traced back to an AWS API key exposed through a misconfigured internal instance that should have been firewalled off. The National Security Agency published a cybersecurity advisory (U/OO/179891-20) specifically about misconfigured IPsec VPN firewall rules allowing adversary lateral movement.
Three concrete scenarios where this tool applies:
1. **Shadowed rules**: You add `-A INPUT -p tcp --dport 80 -j ACCEPT` early in your chain, then later add a more specific rule to block a known malicious subnet on port 80. The specific rule never fires because the broad ACCEPT catches everything first. This is the single most common firewall misconfiguration.
2. **Missing connection tracking**: A ruleset allows SSH on port 22 but has no `ESTABLISHED,RELATED` rule near the top. Every packet in every existing TCP session has to traverse the entire chain instead of matching immediately. On a busy server, this means thousands of unnecessary rule evaluations per second.
3. **No rate limiting on SSH**: Port 22 is open with a plain ACCEPT. An attacker runs hydra or medusa against it with thousands of password attempts per minute. A rate limit of 3/minute with burst 5 would have made brute force impractical.
## What You'll Learn
**Security Concepts:**
- How netfilter processes packets through tables and chains (filter, nat, mangle, raw)
- The difference between iptables (legacy userspace tool) and nftables (its replacement since Linux 3.13)
- Rule evaluation order and why position in the chain determines behavior
- Connection tracking (conntrack) and stateful firewalling with NEW, ESTABLISHED, RELATED, INVALID states
- Default-deny policies vs default-accept, and why the distinction matters
**Technical Skills:**
- The V programming language (syntax, modules, option types, tagged unions, flag enums)
- Parsing domain-specific languages (tokenizing iptables flags, parsing nftables block structure)
- Pairwise conflict detection: checking every rule pair for superset/subset relationships on IPs, ports, and protocols
- CIDR math: converting IP addresses to 32-bit integers and comparing network prefixes with bit shifts
**Tools:**
- `v fmt` for code formatting (V's built-in formatter, similar to gofmt)
- `v test` for running the test suite
- `just` command runner for build, test, format, and smoke test recipes
## Prerequisites
### Required
- Basic networking knowledge: TCP/IP, what ports are, what a firewall does
- Command line familiarity: navigating directories, running commands, reading terminal output
- Any programming language experience: if you can read C, Go, or Python, V will make sense immediately
### Tools
- **V 0.5+** (the install script handles this automatically if you do not have it)
- **just** command runner (optional but makes everything easier)
### Helpful But Not Required
- Linux system administration experience
- Hands-on work with iptables or nftables
- Familiarity with CIDR notation and subnet masks
## Quick Start
```bash
git clone https://github.com/CarterPerez-dev/Cybersecurity-Projects.git
cd PROJECTS/beginner/firewall-rule-engine
./install.sh
fwrule analyze testdata/iptables_conflicts.rules
```
The `analyze` command parses the ruleset, detects that rule 8 (ACCEPT tcp/22 from 10.0.0.0/8) is shadowed by rule 7 (ACCEPT tcp/22 from anywhere), finds that rules 9 and 10 are duplicates (both ACCEPT tcp/80), and flags the contradiction between rule 11 (ACCEPT tcp/443 from 192.168.1.0/24) and rule 12 (DROP tcp/443 from 192.168.0.0/16 which contains 192.168.1.0/24).
Try a few more commands:
```bash
fwrule load testdata/nftables_basic.rules
fwrule harden -s ssh,http,https -f nftables
fwrule export testdata/iptables_basic.rules -f nftables
fwrule diff testdata/iptables_basic.rules testdata/nftables_basic.rules
```
## Project Structure
```
firewall-rule-engine/
├── src/
│ ├── main.v CLI entry point, subcommand dispatch
│ ├── config/
│ │ └── config.v Constants: ports, CIDR ranges, rate limits, service map
│ ├── models/
│ │ └── models.v Core types: Rule, Ruleset, Finding, NetworkAddr, PortSpec
│ ├── parser/
│ │ ├── common.v Shared parsing: protocols, actions, CIDR, port specs
│ │ ├── iptables.v iptables-save format tokenizer and rule parser
│ │ ├── nftables.v nftables block-structured format parser
│ │ └── parser_test.v Parser test suite
│ ├── analyzer/
│ │ ├── conflict.v Pairwise analysis: shadows, contradictions, duplicates
│ │ ├── optimizer.v Optimization: port merging, reordering, missing conntrack
│ │ └── analyzer_test.v Analyzer test suite
│ ├── generator/
│ │ ├── generator.v Hardened ruleset generation, format conversion
│ │ └── generator_test.v Generator test suite
│ └── display/
│ └── display.v Terminal output: tables, colored findings, diffs
├── testdata/
│ ├── iptables_basic.rules Clean iptables ruleset
│ ├── iptables_complex.rules Larger iptables ruleset with NAT and multiple tables
│ ├── iptables_conflicts.rules Intentionally broken rules for testing conflict detection
│ ├── nftables_basic.rules Clean nftables ruleset
│ ├── nftables_complex.rules Larger nftables ruleset
│ └── nftables_conflicts.rules Intentionally broken nftables rules
├── learn/ You are here
├── install.sh One-command setup (installs V if needed, builds, installs)
├── Justfile Build/test/format/smoke-test recipes
├── v.mod V module metadata
└── LICENSE
```
## Next Steps
1. [01-CONCEPTS.md](./01-CONCEPTS.md) - Netfilter architecture, iptables vs nftables, chain evaluation, connection tracking
2. [02-ARCHITECTURE.md](./02-ARCHITECTURE.md) - How the parser, analyzer, and generator modules interact, data flow from raw text to findings
3. [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) - Code walkthrough: tokenization, CIDR math, pairwise conflict detection, hardened ruleset generation
4. [04-CHALLENGES.md](./04-CHALLENGES.md) - Extensions: IPv6 support, live system import, firewalld/ufw parsing, rule visualization
## Common Issues
**`v: command not found` after cloning**
Run `./install.sh`. It clones the V compiler from source, builds it, and adds it to your PATH. If you already ran it and still get the error, restart your shell or run `source ~/.zshrc` (or `~/.bashrc`).
**Tests fail with import errors**
Run tests from the project root, not from inside `src/`:
```bash
v test src/
```
V resolves module imports relative to the project root. Running `v test` from inside a subdirectory breaks the import paths.
**`v fmt` reports formatting errors**
Run the formatter in write mode to fix them automatically:
```bash
v fmt -w src/
```
Or use `just fmt` which does the same thing. To check without modifying files, use `just fmt-check`.
**Binary not found after install**
The install script puts the binary at `~/.local/bin/fwrule`. If that directory is not in your PATH, either add it or run the binary directly:
```bash
~/.local/bin/fwrule analyze testdata/iptables_conflicts.rules
```

View File

@ -0,0 +1,499 @@
<!-- © AngelaMos | 2026 | 01-CONCEPTS.md -->
# Security Concepts
This file covers the theory behind Linux firewalling and the specific problems
that `fwrule` is built to detect. Every concept here ties back to real code in
the project or to real incidents where someone got it wrong and paid for it.
---
## Netfilter Architecture
Every Linux firewall you have heard of (iptables, nftables, firewalld, ufw) is
a frontend for the same kernel framework: **netfilter**. It sits inside the
Linux networking stack and provides five hook points where the kernel can
inspect, modify, or drop packets as they move through the system.
### Packet Flow
When a packet arrives at a Linux machine, it takes one of two paths depending
on the routing table. If the destination IP belongs to this machine, the packet
goes to INPUT. If the destination is somewhere else and IP forwarding is enabled
(`net.ipv4.ip_forward = 1`), it goes to FORWARD. A packet never hits both.
```
NETWORK
|
v
+------------------+
| PREROUTING | raw, mangle, nat (DNAT)
+------------------+
|
Routing Decision
/ \
v v
+----------+ +-----------+
| INPUT | | FORWARD |
| filter, | | filter, |
| mangle, | | mangle, |
| security | | security |
+----------+ +-----------+
| |
v v
Local Process +------------------+
| | POSTROUTING | mangle, nat (SNAT/MASQ)
v +------------------+
+----------+ |
| OUTPUT | v
| raw, | NETWORK
| mangle, |
| nat, |
| filter, |
| security |
+----------+
|
v
+------------------+
| POSTROUTING |
+------------------+
|
v
NETWORK
```
### The Five Hooks
| Hook | When It Fires | Typical Use |
|------|--------------|-------------|
| PREROUTING | Packet just arrived, before routing decision | DNAT (port forwarding), connection tracking entry |
| INPUT | Packet destined for this machine | Filtering inbound traffic to local services |
| FORWARD | Packet passing through (this box is a router) | Filtering between network segments |
| OUTPUT | Packet originated from a local process | Filtering outbound traffic |
| POSTROUTING | Packet about to leave, after routing decision | SNAT, masquerading for NAT gateways |
### Tables
Netfilter organizes rules into five tables, each with a specific job:
| Table | Purpose | Available Chains |
|-------|---------|-----------------|
| **filter** | Accept/drop/reject decisions | INPUT, FORWARD, OUTPUT |
| **nat** | Network Address Translation | PREROUTING, OUTPUT, POSTROUTING |
| **mangle** | Packet header modification (TTL, TOS, marking) | All five |
| **raw** | Bypass connection tracking | PREROUTING, OUTPUT |
| **security** | SELinux/AppArmor labeling | INPUT, FORWARD, OUTPUT |
The `fwrule` tool models all five in its `Table` enum (`src/models/models.v`),
but the vast majority of real-world rulesets live in `filter`. That is where
accept/drop decisions happen, and where misconfigurations cause breaches.
The `raw` table deserves a note: rules here run before conntrack, so you can
mark high-volume traffic (like DNS on a busy resolver) with `NOTRACK` to skip
connection tracking entirely. This matters when the conntrack table fills up
on NAT gateways handling hundreds of thousands of concurrent connections. When
that happens, the kernel drops new connections and you see
`nf_conntrack: table full, dropping packet` in dmesg.
---
## iptables vs nftables
### iptables: The Legacy Tool
`iptables` has been the standard Linux firewall CLI since the 2.4 kernel (2001).
An `iptables-save` dump looks like this:
```
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -p tcp --dport 22 -j ACCEPT
-A INPUT -p tcp --dport 80 -j ACCEPT
-A INPUT -p tcp --dport 443 -j ACCEPT
-A INPUT -j LOG --log-prefix "DROPPED: "
-A INPUT -j DROP
COMMIT
```
The format: `*filter` declares the table, `:INPUT DROP [0:0]` sets the chain
policy and packet/byte counters, `-A INPUT` appends a rule, `-j ACCEPT` is
the jump target, `COMMIT` atomically applies the table.
Limitations worth knowing:
- Separate binaries for IPv4 (`iptables`), IPv6 (`ip6tables`), ARP
(`arptables`), and bridge filtering (`ebtables`)
- Rules are a flat list of conditions with match extensions (`-m conntrack`,
`-m limit`, `-m multiport`)
- Ruleset updates replace one table at a time, not the entire ruleset atomically
### nftables: The Replacement
`nftables` replaced iptables starting with Linux 3.13 (2014). Debian 10+,
RHEL 8+, Fedora 18+, and Ubuntu 20.10+ all default to it. The same ruleset
in nftables syntax:
```
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iifname "lo" accept
ct state established,related accept
tcp dport 22 accept
tcp dport 80 accept
tcp dport 443 accept
log prefix "DROPPED: " drop
}
}
```
### Key Differences
| Feature | iptables | nftables |
|---------|----------|----------|
| IPv4/IPv6 | Separate binaries | Unified (`inet` family) |
| Syntax | Flag-based (`-p tcp --dport 22`) | Expression-based (`tcp dport 22`) |
| Atomicity | Per-table | Entire ruleset in one transaction |
| Sets | No native support | Native sets and maps (`{ 22, 80, 443 }`) |
| Multiple actions | One target per rule | Chain multiple statements |
| Performance | Linear rule matching | Sets use hash lookups (O(1) vs O(n)) |
The set syntax is a concrete improvement. This nftables line:
```
tcp dport { 22, 80, 443 } accept
```
replaces three separate iptables rules. The kernel evaluates the set with a
hash lookup instead of walking three rules sequentially.
### Why Both Still Exist
nftables ships with a compatibility layer (`iptables-nft`) that translates
iptables commands into nftables rules behind the scenes. Many distributions
install this by default, so running `iptables` actually creates nftables rules
without the user knowing. This is why you can run `iptables -L` on a modern
system and see rules, then run `nft list ruleset` and see the same rules in
nftables format.
The `fwrule export` command handles conversion between formats, which is useful
during migration.
---
## Rule Evaluation Order
### First-Match-Wins
This is the single most important concept in firewall configuration: **the
first matching rule wins**. The kernel walks through each rule in order, top
to bottom. The moment a packet matches a rule with a terminating target
(ACCEPT, DROP, REJECT), evaluation stops. The packet never sees the remaining
rules.
Two rulesets with identical rules in different order can have completely
different security properties:
```
Ordering A (secure): Ordering B (broken):
1. -s 10.0.0.5 -p tcp --dport 22 -j DROP 1. -p tcp --dport 22 -j ACCEPT
2. -p tcp --dport 22 -j ACCEPT 2. -s 10.0.0.5 -p tcp --dport 22 -j DROP
```
Ordering A blocks SSH from 10.0.0.5, then allows everyone else.
Ordering B allows SSH from everywhere including 10.0.0.5. Rule 2 is dead code.
Same rules, opposite security outcome.
### Chain Policies
Every built-in chain has a default policy that fires when no rule matches:
```
:INPUT DROP [0:0] <-- default deny (fail-closed)
:INPUT ACCEPT [0:0] <-- default accept (fail-open)
```
Default deny means anything you forgot to allow is blocked. Default accept
means anything you forgot to block gets through. The `fwrule harden` command
always generates `DROP` on INPUT and FORWARD, `ACCEPT` on OUTPUT.
### The Shadowing Problem
Shadowing is the most common firewall misconfiguration. It happens when a
broad rule early in the chain silently prevents a more specific rule later
from ever matching.
Walk through this numbered ruleset from `testdata/iptables_conflicts.rules`:
```
Rule 7: -A INPUT -p tcp --dport 22 -j ACCEPT
Rule 8: -A INPUT -s 10.0.0.0/8 -p tcp --dport 22 -j ACCEPT
Rule 9: -A INPUT -p tcp --dport 80 -j ACCEPT
Rule 10: -A INPUT -p tcp --dport 80 -j ACCEPT
Rule 11: -A INPUT -s 192.168.1.0/24 -p tcp --dport 443 -j ACCEPT
Rule 12: -A INPUT -s 192.168.0.0/16 -p tcp --dport 443 -j DROP
```
What happens:
- **Rule 8 is shadowed by Rule 7.** Rule 7 accepts SSH from any source. Rule 8
accepts SSH only from 10.0.0.0/8. Since Rule 7 already accepted all SSH
traffic, Rule 8 can never fire. The `find_shadowed_rules` function in
`src/analyzer/conflict.v` catches this by checking whether Rule 7's match
criteria is a superset of Rule 8's.
- **Rules 9 and 10 are duplicates.** Both accept TCP port 80 with no source
restriction. Rule 10 is dead weight.
- **Rules 11 and 12 contradict.** 192.168.1.0/24 is inside 192.168.0.0/16.
Hosts in 192.168.1.0/24 match Rule 11 (ACCEPT) first. The rest of
192.168.0.0/16 hits Rule 12 (DROP). This might be intentional, but
overlapping criteria with opposite actions always deserves review. The
`find_contradictions` function flags it.
The tool performs this analysis by running pairwise comparison across every
rule in each chain. For each pair (i, j) where i < j, it calls
`match_is_superset(rules[i].criteria, rules[j].criteria)`. That function
checks protocol, source address, destination address, ports, interfaces,
and conntrack states. If every field of the earlier rule encompasses the
later rule, the later rule is shadowed.
---
## Connection Tracking (conntrack)
### Stateful vs Stateless
Without connection tracking, a firewall is stateless. It evaluates each packet
in isolation with no memory of previous packets. If you allow inbound traffic
to port 80, you also need a separate rule to allow response packets going
back out on ephemeral ports (1024-65535). That is a huge attack surface.
Connection tracking solves this. The kernel maintains a table of every active
connection (stored in `/proc/sys/net/netfilter/nf_conntrack_max`, typically
65536 entries by default, each consuming about 300-400 bytes of kernel memory).
Each tracked flow gets classified into a state.
### The Four States
| State | Meaning | Example |
|-------|---------|---------|
| **NEW** | First packet of a connection | TCP SYN, first UDP datagram |
| **ESTABLISHED** | Part of a bidirectional flow | Anything after the SYN/SYN-ACK exchange |
| **RELATED** | New connection spawned by an existing one | FTP data channel, ICMP error responses |
| **INVALID** | Cannot be associated with any known connection | Corrupted packet, out-of-window TCP sequence |
### Why ESTABLISHED,RELATED Must Be Near the Top
Look at the standard pattern from `testdata/iptables_basic.rules`:
```
-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -p tcp --dport 22 -j ACCEPT
```
On a busy server, 90%+ of packets belong to established connections. If the
conntrack rule is at position 2, those packets match immediately and skip
everything else. If you bury it at position 10, every established packet
walks past 9 rules before it matches. That is thousands of unnecessary rule
evaluations per second under load.
The `find_missing_conntrack` function in `src/analyzer/optimizer.v` detects
two problems: chains with no ESTABLISHED/RELATED rule at all (warning), and
chains where the rule exists but is positioned past the third slot (info
suggestion to move it up).
### RELATED Connections
RELATED is less obvious than ESTABLISHED but equally important. Two scenarios:
**FTP data channels:** FTP uses port 21 for control and a separate connection
for data transfer. The kernel's `nf_conntrack_ftp` helper module watches the
control channel, sees the PORT or PASV command, and marks the resulting data
connection as RELATED. Without RELATED in your conntrack rule, FTP data
transfers break even though port 21 is open.
**ICMP errors:** When a packet is dropped somewhere in the network, the
dropping router sends back an ICMP "destination unreachable" or "time exceeded"
message. These ICMP packets are RELATED to the original connection. Without
RELATED, your machine never receives these error messages, which breaks path
MTU discovery and makes network debugging much harder.
---
## Default Deny vs Default Accept
This is the principle of least privilege applied to network traffic.
**Default deny** (the only sane production policy):
```
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
```
You build a whitelist. Every service that needs to be reachable gets an
explicit ACCEPT rule. Anything you forgot stays blocked. If someone adds a
new service to the machine without adding a firewall rule, the service is
unreachable. That is annoying, but safe. You notice and fix it.
**Default accept** (dangerous):
```
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
```
You build a blacklist. You try to block everything bad and hope you did not
forget anything. When someone installs MySQL on the box and it binds to
0.0.0.0:3306, it is immediately reachable from the entire internet because
you never added a rule to block it. You might not notice for months.
The difference between these two comes down to what happens when something
goes wrong. Default deny fails closed (the safe direction). Default accept
fails open (the dangerous direction). The Palo Alto Unit 42 2023 Cloud Threat
Report found 76% of organizations had publicly exposed SSH in at least one
cloud environment, almost always because of default-accept equivalent
configurations on security groups.
---
## Real-World Breaches
### Capital One (2019)
In March 2019, a former AWS employee exploited a Server-Side Request Forgery
(SSRF) vulnerability in a misconfigured WAF protecting Capital One's AWS
infrastructure. The WAF had an IAM role with excessive permissions, and the
firewall rules allowed the compromised instance to reach the EC2 metadata
service at 169.254.169.254. The attacker queried the metadata endpoint to
obtain temporary IAM credentials, used them to list and download S3 buckets,
and exfiltrated data because outbound traffic was unrestricted.
A single egress firewall rule would have stopped the exfiltration:
```
-A OUTPUT -d 169.254.169.254/32 -j DROP
```
Impact: 100 million credit applications exposed, 140,000 Social Security
numbers, 80,000 bank account numbers. Capital One paid an $80 million fine
to the OCC and $190 million in settlements. (United States v. Paige A.
Thompson, Case No. CR19-159, W.D. Wash. 2019.)
### Imperva (2019)
Imperva disclosed a security incident where an internal database instance
had a misconfigured AWS security group that allowed unauthorized access. The
exposed instance should have been network-isolated, but its security group
rules permitted inbound connections they should not have. An attacker obtained
API keys from the instance and used them to access customer data. The root
cause was a security group that was too permissive on an instance that never
needed external connectivity.
This is the exact pattern `fwrule` flags as "overly permissive": a rule
matching source 0.0.0.0/0 on a port that should be restricted to an internal
subnet.
### NSA Advisory on IPsec VPN Firewalls (U/OO/179891-20)
The National Security Agency published guidance specifically about
misconfigured firewall rules around VPN infrastructure. The advisory
documented how adversaries exploit overly permissive rules on VPN gateways
to gain initial access to a network, then use the same misconfigured
segmentation to move laterally between network zones that should be isolated.
The specific concern: firewall rules that allow VPN traffic to reach internal
subnets without restricting which internal services are accessible, turning
the VPN into a free pass past the perimeter.
### Equifax (2017, CVE-2017-5638)
The root cause was an unpatched Apache Struts vulnerability, but the breach
was dramatically worsened by firewall and network failures. An expired SSL
certificate on a network monitoring device meant encrypted traffic inspection
stopped working for 19 months without anyone noticing. Misconfigured network
segmentation allowed the attacker to move laterally across systems for 76 days
after initial compromise, accessing 48 databases containing records of 147
million people. The combination of no patching, no monitoring, and no
segmentation turned a single web application vulnerability into one of the
largest data breaches in history. The eventual cost exceeded $1.4 billion.
### Docker/Kubernetes Default Networking
This is not a single breach but a widespread class of misconfiguration.
Docker's default bridge network inserts iptables rules directly into the
FORWARD chain and the nat table's PREROUTING chain. These rules bypass
host-level firewalls like UFW and firewalld, because Docker's rules are
evaluated before the host firewall's rules in the chain.
What this means in practice: you set up UFW on a Docker host and add rules
to block port 3306. Docker publishes a MySQL container on port 3306. UFW
reports the port as blocked. The port is actually open to the internet because
Docker's iptables rules in the FORWARD chain accept the traffic before it ever
reaches UFW's rules.
```
Packet arrives
|
v
PREROUTING (Docker DNAT rule matches, rewrites destination)
|
v
FORWARD chain
|
+-> Docker's ACCEPT rule fires here <-- UFW never sees this packet
|
+-> UFW's rules (never reached)
```
Kubernetes has the same problem at scale. kube-proxy generates iptables or
nftables rules for every Service object. On a cluster with hundreds of
services, there can be thousands of generated rules that no human wrote or
reviewed. These rules interact with the host firewall in ways that are not
obvious from looking at either the Kubernetes configuration or the host
firewall configuration alone.
---
## Common Firewall Mistakes
These are the specific patterns `fwrule analyze` and `fwrule optimize` detect.
Each one maps to a function in `src/analyzer/conflict.v` or
`src/analyzer/optimizer.v`:
- **Shadowed rules** (`find_shadowed_rules`): A broad ACCEPT before a specific
DENY makes the DENY unreachable. Severity: CRITICAL.
- **Missing conntrack** (`find_missing_conntrack`): No ESTABLISHED/RELATED rule
means every packet walks the full chain. On a busy server, this is measurable
in CPU. Severity: WARNING.
- **No rate limiting on SSH** (`find_missing_rate_limits`): Port 22 open with a
plain ACCEPT. An attacker runs hydra with thousands of password attempts per
minute. A limit of 3/minute with burst 5 makes brute force impractical.
Severity: WARNING.
- **Duplicate rules** (`find_duplicates`): Two rules with identical match
criteria and the same action. The second one is dead weight that makes
auditing harder. Severity: WARNING.
- **Contradictory rules** (`find_contradictions`): Overlapping match criteria
with opposite actions (ACCEPT vs DROP). Might be intentional, but needs
human review. Severity: WARNING.
- **Default accept policy**: The chain's policy is ACCEPT, so anything not
explicitly blocked gets through. This is the single most common
misconfiguration on internet-facing servers.
- **Redundant rules** (`find_redundant_rules`): A narrow rule with the same
action as a broader rule that already covers it. Not a security risk, but
clutter that obscures the actual policy. Severity: INFO.
- **Missing logging** (`find_missing_logging`): A chain with a DROP policy but
no LOG rule. Dropped traffic generates no audit trail, which makes incident
response and forensics significantly harder. Severity: INFO.

View File

@ -0,0 +1,516 @@
<!-- © AngelaMos | 2026 | 02-ARCHITECTURE.md -->
# Architecture
## System Overview
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 |
## Module Layout
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:
- `config` and `models` are leaf modules. They import nothing from the project.
- `parser`, `analyzer`, `generator`, and `display` never import each other. Zero cross-dependencies.
- `main` is 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.
## Data Flow
### `load` command
```
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.
### `analyze` command
```
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.
### `harden` command
```
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`.
### `export` command
```
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.
### `diff` command
```
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.
## Core Types
All types live in `src/models/models.v`. The parser produces them, the analyzer inspects them, the generator and display modules consume them.
### Ruleset
```
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
```
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
```
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
```
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 and PortSpec
```
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.
### ConnState as a @[flag] enum
```
@[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.
## Parser Design
The parser solves a two-format problem. iptables-save and nft list ruleset express the same firewall concepts but with completely different syntax.
### iptables parser (iptables.v)
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 parser (nftables.v)
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 }`).
### Shared parsing layer (common.v)
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.
## Analyzer Design
### Pairwise comparison
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.
### Four conflict types
```
┌────────────────┬─────────────────────────────────────────────────────┐
│ 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. │
└────────────────┴─────────────────────────────────────────────────────┘
```
### Superset/subset math
"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.
### Why findings carry rule indices
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.
## Generator Design
### Template-based hardened rulesets
`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.
### Format export
`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.
## Design Decisions
### Why V
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.
### Why pairwise comparison instead of a decision tree or BDD
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.
### Why no external dependencies
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.
### Why separate modules instead of a single file
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.

View File

@ -0,0 +1,576 @@
<!-- © AngelaMos | 2026 | 03-IMPLEMENTATION.md -->
# Code Walkthrough
This document walks through the actual source code file by file. Every function reference includes its location so you can open it and read alongside. The goal is to make the implementation legible, not to repeat the code verbatim. Open the files in your editor as you read.
---
## V Language Patterns Used
Quick reference of V patterns you will see throughout the codebase. If you already know Go or C, most of this will feel familiar. The main things that might surprise you are option types and flag enums.
**Module system.** Every file starts with `module name`. Imports use selective syntax: `import src.models { Rule, Ruleset, Finding }` pulls specific types into scope without requiring the `models.` prefix at every call site. The directory name is the module name. All files in a directory share the same module scope, which is how test files access private functions.
**Option types.** `?Type` means the value might be `none`. Two ways to unwrap:
- `if val := optional { ... }` where the `:=` inside `if` binds the unwrapped value only if it is not `none`
- `val := opt or { default }` for providing a fallback
The parser uses option types heavily for fields like `source ?NetworkAddr` and `in_iface ?string` in the `MatchCriteria` struct in `models.v`. A firewall rule might or might not specify a source address, and "not specified" is semantically different from any address value. The option type makes this distinction impossible to forget.
**Result types.** `!Type` means the function might return an error. Same unwrap patterns as option types. The `!` propagation operator lets callers bubble errors upward without writing error-handling boilerplate: `parse_network_addr(tokens[i])!` returns the error to the caller if parsing fails. Most parse functions return `!` because input can always be malformed.
**Flag enums.** The `@[flag]` attribute on an enum declaration makes it a bitfield instead of a single-value enum. Each variant occupies one bit position. The `ConnState` enum in `models.v` uses this so a single variable can hold any combination of connection states. The operations: `.has()` tests one flag, `.set()` turns one on, `.all()` checks if all flags in one value are present in another, `.is_empty()` checks if no flags are set, `.zero()` creates a value with no flags set.
**String interpolation.** `'text ${expression} more text'` with `${}` for any expression. V calls the `.str()` method automatically when you interpolate a type that has one, which is why every enum in models.v defines a `str()` method.
**Array methods.** `.map()`, `.filter()`, `.any()`, `.all()`, `.contains()` work like you would expect from functional languages. The implicit `it` variable refers to the current element. For example, in the `find_mergeable_ports` function in `optimizer.v`, `entries.map(it[0])` pulls the first element from each sub-array to extract rule indices.
**`in` operator.** Checks membership in arrays: `dp.start in high_traffic_ports` in the `suggest_reordering` function in `optimizer.v`. Also works on maps: `key !in tables_seen` in the `export_as_iptables` function in `generator.v`.
**`mut` for mutability.** Variables and parameters are immutable by default. Declare `mut i := 0` to allow mutation. Function parameters that the function modifies must also be declared `mut` in the signature, like the `parse_nft_table` function in `nftables.v` which takes `mut ruleset Ruleset`.
---
## CLI Entry Point (main.v)
`src/main.v`
The entry point is a subcommand dispatcher. The `main` function checks `os.args.len`, extracts the subcommand from `os.args[1]`, and dispatches via a `match` statement to one of: `cmd_load`, `cmd_analyze`, `cmd_optimize`, `cmd_harden`, `cmd_export`, `cmd_diff`, `cmd_version`, `cmd_help`. Unknown commands print an error and exit with `config.exit_usage_error`.
Each command function follows the same pattern: validate arguments, call `load_ruleset` to parse the input file, then call the appropriate module functions and display the results.
**`load_ruleset`**: The bridge between the CLI and the rest of the system. It reads the file, calls `parser.detect_format` to auto-detect iptables vs nftables, and dispatches to the correct parser:
```v
fn load_ruleset(path string) !models.Ruleset {
if !os.exists(path) {
return error('file not found: ${path}')
}
content := os.read_file(path) or { return error('cannot read file: ${path}') }
fmt := parser.detect_format(content)!
return match fmt {
.iptables { parser.parse_iptables(content)! }
.nftables { parser.parse_nftables(content)! }
}
}
```
All three steps propagate errors with `!`, so a bad file path, unrecognized format, or parse failure each produce a clean error message.
**`cmd_harden`**: The most complex command because it accepts flags. It uses V's `flag` module to parse `--services` (comma-separated service names, defaults to `config.default_services`), `--iface` (network interface, defaults to `config.default_iface`), and `--format` (iptables or nftables). After parsing flags, it splits the service string on commas, trims whitespace, and calls `generator.generate_hardened`.
**`cmd_analyze`**: Loads the ruleset, prints a summary, then runs both conflict detection (`analyzer.analyze_conflicts`) and optimization suggestions (`analyzer.suggest_optimizations`). The results are printed as two separate sections with their own headers.
**`cmd_diff`**: Loads two rulesets from two different files and passes both to `display.print_diff`. Because `load_ruleset` auto-detects format, you can diff an iptables file against an nftables file. The comparison uses the canonical `Rule.str()` form which normalizes both formats to the same representation.
---
## Config Module (config.v)
`src/config/config.v`
This file is nothing but `pub const` declarations. No functions, no logic, no state. Everything the rest of the codebase needs as a fixed value lives here. The point is that no other file contains a magic number or string literal that could drift out of sync.
**Exit codes**: `exit_success = 0`, `exit_parse_error = 1`, `exit_file_error = 2`, `exit_analysis_error = 3`, `exit_usage_error = 64`. The usage error code is 64 following the BSD `sysexits.h` convention, which reserves codes 64-78 for program-specific errors. The CLI entry point in `main.v` uses these for every `exit()` call.
**Well-known ports**: Named constants for SSH (22), DNS (53), HTTP (80), HTTPS (443), SMTP (25), NTP (123). The `find_missing_rate_limits` function in `optimizer.v` references `port_ssh` when checking for missing rate limits. The generator uses these indirectly through the `service_ports` map.
**CIDR and network ranges**: `cidr_max_v4 = 32`, `private_ranges` listing RFC 1918 space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), and loopback addresses for v4 and v6. The `generate_iptables_hardened` function in `generator.v` iterates `private_ranges` to build anti-spoofing rules that drop packets claiming to originate from private addresses when arriving on the public interface.
**Rate limits**: `ssh_rate_limit = '3/minute'`, `ssh_rate_burst = 5`, `icmp_rate_limit = '1/second'`, `icmp_rate_burst = 5`. These feed directly into the hardened template generation. SSH rate limiting makes brute-force attacks impractical: 3 new connections per minute with a burst allowance of 5. ICMP rate limiting prevents ping flood attacks while still allowing legitimate echo requests.
**Display constants**: Column widths for the rule table (`col_num = 5`, `col_chain = 12`, etc.) and Unicode symbols for the terminal output (`sym_check`, `sym_cross`, `sym_warn`, `sym_arrow`, `sym_bullet`). The display module uses these to build fixed-width columns without depending on terminal width detection.
**Service map**: `service_ports` maps service names to port numbers. This is a `map[string]int` literal. When you run `fwrule harden -s ssh,http,https`, the generator looks up each name in this map via `config.service_ports[svc] or { continue }`. Unknown service names are silently skipped. The map covers ssh, dns, http, https, smtp, ntp, ftp, mysql, pg (PostgreSQL), and redis.
---
## Models Module (models.v)
`src/models/models.v`
This file defines every type the rest of the codebase operates on, plus the CIDR math functions that the analyzer depends on. It is the shared vocabulary between all modules.
### Enums
Six enums: `Protocol`, `Action`, `Table`, `ChainType`, `RuleSource`, `Severity`. Each has a `.str()` method returning the canonical string form. All use `as u8` backing type to keep memory small.
`Protocol`: tcp, udp, icmp, icmpv6, all, sctp, gre. The `all` variant matches any protocol and is the default when a rule does not specify one.
`Action`: Covers the full iptables target set. The `return_action` variant is named that way because `return` is a V keyword. The `.str()` method maps it to `"RETURN"`.
`Table`: filter, nat, mangle, raw, security. Most rulesets only use filter. The complex testdata files use filter and nat together.
`ChainType`: input, output, forward, prerouting, postrouting, custom. The `custom` variant is the catch-all for user-defined chains.
### ConnState as a Flag Enum
```v
@[flag]
pub enum ConnState {
new_conn
established
related
invalid
untracked
}
```
Five variants. Because it is a flag enum, each variant is a single bit. `new_conn` is bit 0, `established` is bit 1, `related` is bit 2, and so on. A single `ConnState` value can hold any combination. When the parser reads `ESTABLISHED,RELATED`, it calls `.set(.established)` and `.set(.related)`, producing a value where bits 1 and 2 are set: `0b0110`.
Why bitfields instead of an array? Because the analyzer needs to compare state sets efficiently. "Does the outer rule's state set contain all the inner rule's states?" is a single `outer.states.all(inner.states)` call, which is a bitwise AND under the hood. An array comparison would need nested loops.
### NetworkAddr
Three fields: `address` (string), `cidr` (int, defaults to 32), `negated` (bool). A plain IP like `192.168.1.1` gets `cidr = 32` (a single host). A CIDR like `10.0.0.0/8` gets `cidr = 8`. Negated addresses from `! -s 10.0.0.0/8` get `negated = true`.
### ip_to_u32
The `ip_to_u32` function in `models.v` converts a dotted-quad IP string to a 32-bit unsigned integer for bit-level CIDR math. It splits on `.`, validates that there are exactly four octets, then processes each one. Each octet is validated character-by-character to ensure only ASCII digits are present:
```v
for ch in trimmed.bytes() {
if ch < `0` || ch > `9` {
return error('invalid octet in address: ${ip}')
}
}
val := trimmed.int()
if val < 0 || val > 255 {
return error('invalid octet in address: ${ip}')
}
result = (result << 8) | u32(val)
```
The per-character validation rejects anything that is not a digit before calling `.int()` for the conversion, and a range check catches values outside 0-255. The shift-and-OR accumulates the four octets into a single 32-bit value.
Walk through `192.168.1.1`:
| Iteration | Octet | result before shift | << 8 | \| octet | result |
|-----------|-------|-------------------|----------|-------------|------------|
| 1 | 192 | 0 | 0 | 192 | 192 |
| 2 | 168 | 192 | 49152 | 49320 | 49320 |
| 3 | 1 | 49320 | 12625920 | 12625921 | 12625921 |
| 4 | 1 | 12625921 | 3232235776| 3232235777 | 3232235777 |
The final value `3232235777` equals `0xC0A80101`, which is `192*2^24 + 168*2^16 + 1*2^8 + 1`. This packs four bytes into one integer, which is the standard representation for IPv4 addresses in networking code.
### cidr_contains
The `cidr_contains` function in `models.v` determines if the `inner` network falls within the `outer` network. Three checks:
First: if `outer.cidr > inner.cidr`, return false immediately. A /24 cannot contain a /8. The outer network must be equal width or broader.
Second: if `outer.cidr == 0`, return true immediately. A /0 network covers the entire IPv4 address space, so any inner network is contained:
```v
if outer.cidr == 0 {
return true
}
```
Third: compute `shift = 32 - outer.cidr` and compare `(outer_ip >> shift) == (inner_ip >> shift)`. This right-shifts both IPs to discard the host bits, keeping only the network prefix. If the prefixes match, inner is inside outer.
Concrete examples:
Does `10.0.0.0/8` contain `10.1.2.3/32`? outer.cidr (8) <= inner.cidr (32), passes the first check. Shift = 24. `ip_to_u32("10.0.0.0") >> 24 = 10`. `ip_to_u32("10.1.2.3") >> 24 = 10`. Network prefixes match. Yes.
Does `10.0.0.0/8` contain `172.16.0.0/12`? outer.cidr (8) <= inner.cidr (12), passes. Shift = 24. `ip_to_u32("10.0.0.0") >> 24 = 10`. `ip_to_u32("172.16.0.0") >> 24 = 172`. Prefixes differ. No.
Does `192.168.1.0/24` contain `192.168.0.0/16`? outer.cidr (24) > inner.cidr (16). Fails the first check immediately. No. A /24 is narrower than a /16.
Does `192.168.0.0/16` contain `192.168.1.0/24`? outer.cidr (16) <= inner.cidr (24), passes. Shift = 32 - 16 = 16. `ip_to_u32("192.168.0.0") >> 16 = 49320`. `ip_to_u32("192.168.1.0") >> 16 = 49320`. Equal. Yes. The entire 192.168.1.0/24 block sits inside the 192.168.0.0/16 block.
Does `0.0.0.0/0` contain `192.168.1.0/24`? outer.cidr (0) <= inner.cidr (24), passes the first check. The early `outer.cidr == 0` check returns true without any bit math. Yes.
This function is called by the `addr_is_superset` helper in `conflict.v` whenever it needs to determine whether one address range covers another. The shadowed-rules example from the overview (10.0.0.0/8 shadows 10.0.0.0/24) works because `cidr_contains` correctly identifies the /8 as containing the /24.
### PortSpec
Start port, optional end port (defaults to -1 meaning single port), and negated flag. The `effective_end` method normalizes single ports by returning `start` when `end < 0`. This means all port comparison code can treat every `PortSpec` as a range without special-casing singles. The `port_range_contains` function then just checks `outer.start <= inner.start && outer.effective_end() >= inner.effective_end()`.
### MatchCriteria
The struct that holds everything a rule can match on. Eleven fields: protocol (defaults to `.all`), source and destination (both `?NetworkAddr`), src/dst port lists, in/out interface (both `?string`), connection states, ICMP type, rate limit, limit burst, and comment. The option types for address and interface fields are not just a convenience. They encode the semantic difference between "this rule does not filter on source address" and "this rule filters on a specific source address". The conflict detector relies on this distinction: a `none` source means "matches all sources", which is a superset of any specific source.
### Rule
Wraps `MatchCriteria` with metadata: table, chain name, chain type, action, target arguments, line number from the source file, raw text, and source format. The `line_number` field preserves the original file position for error reporting. The `raw_text` field stores the unparsed line for display. The `str` method produces a tab-separated canonical form (`chain\tprotocol\tsource\tdest\tports\taction`) that the diff module uses to compare rules regardless of format.
### Ruleset
A list of rules, a map of chain name to default policy, and the source format. The `pub mut:` visibility makes `rules` and `policies` mutable from outside the module, which the parsers need when building the ruleset incrementally. The `rules_by_chain` method groups rule indices by chain name into `map[string][]int`. Both the analyzer and generator call this to iterate per-chain instead of scanning the flat list repeatedly.
---
## Parsing: Common Functions (common.v)
`src/parser/common.v`
Shared parsing functions used by both the iptables and nftables parsers.
**`parse_network_addr`**: Handles three address formats: plain IP (`192.168.1.1` defaults to /32 CIDR), CIDR notation (`10.0.0.0/8`), and negated (`!172.16.0.0/12`). The negation prefix is stripped first, then the address is split on `/` if present. Validates prefix length is between 0 and 128 (the upper bound accommodates IPv6 addresses even though the current CIDR math only handles v4).
**`parse_port_spec`**: Same pattern as addresses: strip `!` for negation, split on `:` for ranges. Port ranges use colon separator (`1024:65535`), matching the iptables convention. Single ports get `end = -1`. Validates all ports are within 0-65535.
**`parse_port_list`**: Splits a comma-separated string and calls `parse_port_spec` on each piece. Used when the iptables parser encounters `--dports 80,443,8080`.
**`parse_protocol`**: Maps string names and IANA protocol numbers to `Protocol` variants. Accepts `'tcp'`, `'TCP'`, and `'6'` as equivalent. The number mappings (6 for TCP, 17 for UDP, 1 for ICMP, 58 for ICMPv6, 132 for SCTP, 47 for GRE) match IANA assignments, which is what kernel-level tools sometimes emit instead of names.
**`parse_action`**: Maps action strings to `Action` variants. Case insensitive via `.to_upper()`.
**`parse_chain_type`**: Maps chain name strings to `ChainType` variants. Anything not recognized (INPUT, OUTPUT, FORWARD, PREROUTING, POSTROUTING) becomes `.custom`.
**`parse_conn_states`**: Splits a comma-separated state string and sets flags on a `ConnState` bitfield. Starts with `ConnState.zero()` (all bits clear) and calls `.set()` for each recognized name. Unknown state names are silently ignored via the `else {}` branch.
**`detect_format`**: Looks at the first non-empty, non-comment line. Lines starting with `*` (table header like `*filter`) signal iptables. Lines starting with `table` signal nftables. Lines starting with `:` (chain policy) or `-A`/`-I` (rule) also signal iptables. If nothing matches, returns an error. This auto-detection is called in `load_ruleset` in `main.v` so users never need to specify the input format.
---
## Parsing: iptables Format (iptables.v)
`src/parser/iptables.v`
**`tokenize_iptables`**: A byte-by-byte tokenizer that handles quoted strings. Three state variables: `in_quote` tracks whether the scanner is inside a quoted region, `quote_char` remembers which quote character (`"` or `'`) opened it, and `current` accumulates bytes for the token being built.
The scanner iterates over each byte of the line. In normal mode, spaces and tabs flush the current token into the output list. Quote characters switch to quoted mode. In quoted mode, only the matching close quote ends the token; spaces are accumulated as part of the value. The quote characters themselves are stripped from the output, so `"DROPPED: "` becomes `DROPPED: `. This matters for iptables log prefixes and comments that contain spaces.
The parser test in `parser_test.v` validates quoted string handling directly: tokenizing `-j LOG --log-prefix "DROPPED: "` produces 4 tokens, with the fourth being `DROPPED: ` (trailing space preserved, quotes stripped).
**`parse_iptables`**: Line-by-line iteration with three line types:
- Lines starting with `*` set the current table context via `parse_table`. The `current_table` variable carries forward to all subsequent rules until the next table header appears. This is how `iptables_complex.rules` (which has both `*filter` and `*nat` sections) assigns the correct table to each rule.
- Lines starting with `:` define chain policies via `parse_chain_policy`. `:INPUT DROP [0:0]` becomes chain `INPUT` with policy `DROP`. The `[0:0]` packet/byte counters are ignored.
- Lines starting with `-A` or `-I` are parsed as rules by `parse_iptables_rule`, passing the current table and 1-based line number.
- Blank lines, comments (starting with `#`), and `COMMIT` lines are skipped.
**`parse_chain_policy`**: Strips the leading `:`, splits on space, returns the chain name and policy action as a tuple. Uses V's multi-return: `!(string, Action)`.
**`parse_iptables_rule`**: The main rule parser. After tokenizing the line, it initializes mutable variables for every possible rule field, then walks the token array with a mutable index `i` and a match statement covering every recognized flag:
| Flag(s) | What it sets |
|-------------------------------|---------------------------------------------------|
| `-A`, `-I` | Chain name (next token) |
| `-p`, `--protocol` | Protocol via `parse_protocol` |
| `-s`, `--source` | Source `NetworkAddr`, with negation check |
| `-d`, `--destination` | Destination `NetworkAddr`, with negation check |
| `--sport`, `--source-port` | Single source port, with negation check |
| `--dport`, `--destination-port`| Single destination port, with negation check |
| `--dports` | Multiport list via `parse_port_list` |
| `--sports` | Multiport source list |
| `-i`, `--in-interface` | Input interface name |
| `-o`, `--out-interface` | Output interface name |
| `--state`, `--ctstate` | Connection states via `parse_conn_states` |
| `--icmp-type` | ICMP type string |
| `--limit` | Rate limit string |
| `--limit-burst` | Burst count |
| `--comment` | Comment string |
| `-j`, `--jump` | Action; remaining tokens become target arguments |
| `-m`, `--match` | Consumed and skipped (extension name not stored) |
**Negation handling**: When `!` appears as its own token, a `next_negated` boolean is set to true. The next address or port parsed checks this flag, creates the struct with `negated: true`, and resets the flag to false. This two-phase approach avoids lookahead and keeps the tokenizer completely unaware of iptables semantics.
**Multiport**: The `--dports` token triggers `parse_port_list` from `common.v`, which splits on commas. `-m multiport --dports 80,443` produces two `PortSpec` entries in `dst_ports`. The `-m multiport` part is consumed by the `-m` handler which just advances the index past the extension name. Note that `--dports` replaces the entire `dst_ports` array rather than appending, because multiport defines the complete port list.
**Target arguments**: After parsing the action from `-j`, the parser checks if any remaining tokens start with `--`. If so, it collects all remaining tokens into `target_args`. This captures things like `--log-prefix "DROPPED: "` and `--to-destination 10.0.0.1:8080`.
---
## Parsing: nftables Format (nftables.v)
`src/parser/nftables.v`
nftables uses a block structure (`table { chain { rule } }`) instead of flat flags. The parser tracks nesting through a hierarchy of functions.
**`parse_nftables`**: Top-level loop. When it sees a line starting with `table`, it calls `parse_nft_table` which returns both a `Table` value and the next line index to process. This tuple return (`!(Table, int)`) is the V idiom for consuming variable numbers of lines without mutation of a shared counter.
**`parse_nft_table`**: Extracts the table name from the header by filtering out known family keywords (`table`, `inet`, `ip`, `ip6`, `arp`, `bridge`, `netdev`). The first token that is not one of these keywords is the table name. This handles all nftables family prefixes without maintaining an explicit list of valid families. It then scans lines until the closing `}`, delegating lines starting with `chain` to `parse_nft_chain`.
**`parse_nft_chain`**: Extracts the chain name from lines like `chain input {`, uppercases it to normalize to the iptables convention (`input` becomes `INPUT`). Lines starting with `type` are chain metadata (hook declaration with policy). All other non-empty, non-comment lines are parsed as rules. A failed rule parse uses `or { i++; continue }` to skip unparseable lines without aborting the file.
**`extract_nft_policy`**: Parses `type filter hook input priority 0; policy drop;`. It splits on `;`, finds the segment starting with `policy`, strips the keyword, and parses the remaining text as an action. Returns `?Action` so chains without explicit policies return `none`.
**`parse_nft_rule`**: Token-based like the iptables parser, but matching nftables keywords instead of dash-flags. The tokens are produced by splitting the line on spaces, then filtering empties.
| Expression | What it sets |
|------------------------------|------------------------------------------------|
| `tcp`, `udp` | Protocol, then `parse_nft_port_match` for ports|
| `ip saddr X` | Source `NetworkAddr` |
| `ip daddr X` | Destination `NetworkAddr` |
| `ip protocol X` | Protocol by name |
| `ct state X` | Connection states via `parse_conn_states` |
| `iifname X`, `iif X` | Input interface (quotes stripped) |
| `oifname X`, `oif X` | Output interface (quotes stripped) |
| `limit rate X` | Rate limit (multi-token, collects until action)|
| `log` | LOG action with optional `prefix` argument |
| `counter` | Skipped (counter is metadata, not match logic) |
| `comment X` | Comment string (quotes stripped) |
| `accept`/`drop`/`reject`/etc. | Terminal action |
**Rate limit parsing**: The `limit rate` expression in nftables spans a variable number of tokens, which makes it one of the trickier parsing sequences. After seeing `limit` and then `rate`, the parser enters a collection loop that accumulates tokens into `rate_parts` until it encounters an action keyword (`accept`, `drop`, `reject`, `log`) or `counter`. The collected tokens are joined with spaces and stored as the rate string. For a rule like `limit rate 3/minute burst 5 packets accept`, this produces `limit_rate = "3/minute burst 5 packets"`. The `continue` after storing the rate ensures the loop re-examines the action token in the next iteration instead of consuming it as part of the rate string.
**Action as bare keyword**: In nftables, actions are not flagged with `-j`. They appear as standalone tokens at the end of the rule: `accept`, `drop`, `reject`, `masquerade`, `return`, `queue`. The parser checks for these and sets the action directly. The `log` action gets special handling because it can have a `prefix` argument that needs to be captured into `target_args`. If no action keyword is found by the end of the tokens, the parser returns an error:
```v
final_action := action or { return error('no action found in rule: ${line}') }
```
**Set syntax** (the `parse_nft_port_match` function): nftables uses `{ 80, 443 }` for port sets. When the token after `dport` is `{`, the parser collects tokens until `}`, strips commas with `.replace(',', '')`, and parses each as a port spec. The comma removal is necessary because the space-based tokenization leaves commas attached to port numbers (`"80,"` instead of `"80"`). Single ports without braces take the simpler path, parsing one token directly.
The `parse_nft_port_match` function returns the updated index so the caller can resume iteration at the right position. Both `is_dport` and `is_sport` are determined by checking the token at the start position. The parsed ports are appended to the appropriate mutable array (`dst_ports` or `src_ports`) that was passed by reference.
**Protocol and port coupling**: In nftables, protocol and port are part of the same expression: `tcp dport 22`. The parser handles this by setting the protocol when it sees `tcp` or `udp`, then immediately calling `parse_nft_port_match` to check if the next token is `dport` or `sport`. If it is, the function handles the port parsing and returns the new index. If the next token is neither `dport` nor `sport`, the function returns the index unchanged and no ports are added. The `continue` skips the default `i++` at the bottom of the loop since the index has already been advanced by the port matcher.
---
## Conflict Detection (conflict.v)
`src/analyzer/conflict.v`
**`analyze_conflicts`**: Gets the chain-grouped indices from `rs.rules_by_chain()`, then for each chain runs four detection passes: `find_duplicates`, `find_shadowed_rules`, `find_contradictions`, `find_redundant_rules`. Grouping by chain first is critical because netfilter evaluates rules within a single chain sequentially. A rule in INPUT cannot shadow a rule in FORWARD since they process different traffic flows entirely.
### match_is_superset
The central function for conflict detection. Determines if `outer` criteria match a strict superset of the traffic that `inner` criteria match. Every dimension must pass the superset test. The function returns false at the first dimension that fails, providing an early exit.
1. **Protocol**: If outer is `.all`, it matches any protocol, so it is always a superset. If outer specifies a protocol that differs from inner's, it cannot be a superset.
2. **Source address**: Delegates to `addr_is_superset`. The logic:
- outer is `none` (no source filter) -> superset of anything, return true
- outer is some, inner is `none` (inner matches everything) -> outer cannot cover "everything", return false
- both are some -> delegate to `cidr_contains` from models.v
3. **Destination address**: Same pattern as source.
4. **Destination and source ports**: Delegates to `ports_is_superset`. Empty outer list means "match all ports", which is a superset of anything. Non-empty outer must cover every port in inner's list. For each inner port range, at least one outer port range must fully contain it (checked via `port_range_contains`).
5. **Interfaces**: Delegates to `iface_is_superset`. `none` outer is superset. Both present must be equal strings.
6. **Connection states**: If outer has state constraints (not empty), inner must have all of them. `outer.states.all(inner.states)` performs a bitwise check. If outer has no state constraints, it passes regardless.
### matches_overlap
Similar to `match_is_superset` but bidirectional. Two criteria overlap if they could match the same packet. For protocols, overlap requires either one being `.all` or both matching. For addresses, `addrs_overlap` checks if either direction of `cidr_contains` holds (A contains B or B contains A). For ports, `ports_overlap` checks if any pair of port ranges from the two lists intersect using `pa.start <= pb.effective_end() && pb.start <= pa.effective_end()`.
### Detection Passes
**`find_shadowed_rules`**: Nested loop with `i < j`, so rule `i` always appears before rule `j` in the chain. A shadow is detected when `match_is_superset(rules[i].criteria, rules[j].criteria)` is true AND the two rules have different actions (`rules[i].action != rules[j].action`):
```v
if match_is_superset(rules[i].criteria, rules[j].criteria)
&& rules[i].action != rules[j].action {
findings << Finding{
severity: .critical
title: 'Shadowed rule detected'
description: 'Rule ${indices[j] + 1} (${rules[j].action.str()}) can never match because rule ${
indices[i] + 1} (${rules[i].action.str()}) catches all its traffic first'
rule_indices: [indices[i], indices[j]]
suggestion: 'Remove rule ${indices[j] + 1} or reorder it before rule ${
indices[i] + 1}'
}
}
```
The action comparison is key: if both rules have the same action, the later rule is redundant (wasteful but harmless) rather than shadowed (actively wrong). Shadows only fire when the actions differ, because that means the later rule's intended behavior can never take effect. Since netfilter processes rules top to bottom and stops at the first match, the later rule is dead code. This is CRITICAL severity because the rule has zero effect regardless of intent.
Concrete scenario: rule 7 is `DROP tcp/22` (block SSH from anywhere) and rule 8 is `ACCEPT tcp/22 from 10.0.0.0/8` (allow SSH from the 10.x range). Rule 7 has no source constraint, so `match_is_superset(rule7, rule8)` is true: the protocol matches (both TCP), `addr_is_superset(none, 10.0.0.0/8)` is true (no constraint is superset of any constraint), and port 22 matches port 22. The actions differ (DROP vs ACCEPT), so this is a shadow. Rule 8 never fires.
**`find_contradictions`**: Also `i < j`. Two rules contradict when their matches overlap and their actions conflict (one allows, the other denies). The key subtlety is that if one rule is a pure superset of the other, the function skips it. That case is already reported as a shadow. Contradictions only apply to partial overlaps where some packets match both rules but neither rule completely covers the other.
Concrete scenario: rule 11 is `ACCEPT tcp/443 from 192.168.1.0/24` and rule 12 is `DROP tcp/443 from 192.168.0.0/16`. Their source addresses overlap (the /24 is inside the /16), ports match, and actions conflict (ACCEPT vs DROP). But rule 12 is not a pure superset of rule 11 because rule 12 also matches sources like 192.168.2.0/24 that rule 11 does not. So this is a contradiction, not a shadow.
The `actions_conflict` function defines conflict as one "allow-like" (ACCEPT) and one "deny-like" (DROP or REJECT). DROP vs REJECT is not a conflict (both deny). ACCEPT vs ACCEPT is not a conflict (both allow).
**`find_duplicates`**: Uses `criteria_equal` combined with action equality. Two rules are duplicates only when every field matches exactly: same protocol, same source, same destination, same ports, same interfaces, same states, and same action. This is the simplest detection pass.
**`find_redundant_rules`**: Superset relationship with the same action. A broad `ACCEPT tcp` and a narrow `ACCEPT tcp dport 80` for the same chain mean the narrow rule does nothing because the broad rule already accepts all TCP, including port 80. This differs from shadows in two ways: the actions must match, and the finding is INFO severity rather than CRITICAL (the narrow rule is harmless, just wasteful). The check explicitly excludes exact duplicates (`!criteria_equal(...)`) since those are reported by `find_duplicates`.
### Helper Functions
The conflict detector has a layer of helper functions that handle the option-type unwrapping and comparison logic for each field type. Understanding these is key to understanding why the superset and overlap checks work correctly.
**`addr_is_superset`**: This function demonstrates the option-type pattern used throughout the conflict module. The logic reads as a truth table:
| outer | inner | result | reasoning |
|---------|---------|--------|----------------------------------------------------|
| `none` | `none` | true | no filter is superset of no filter |
| `none` | some | true | no filter matches everything, superset of anything |
| some | `none` | false | specific filter cannot cover "everything" |
| some | some | cidr | delegate to `cidr_contains` |
This pattern repeats for `iface_is_superset`: `none` outer is superset of anything, both present must be equal strings. And for `ports_is_superset`: empty outer list is superset of anything, non-empty outer must cover all inner ports.
The consistent rule is: "no constraint" is always a superset (it matches everything), and "some constraint" can only be a superset if it demonstrably covers the other.
**`criteria_equal`**: Field-by-field equality check. Uses `addrs_equal` for address comparison: both `none` is equal, both present must match address, CIDR, and negation flag, one `none` and one present is not equal. Uses `ports_equal` for port lists: must be same length, and each pair must match start, effective_end, and negation. Uses `opt_str_equal` for optional strings like interface names: both `none` is equal, both present must be identical, mixed is not equal.
**`addrs_overlap`**: If either address is `none`, overlap is true (an unfiltered dimension matches everything, so any specific value overlaps with it). If both are present with different negation flags, overlap is conservatively assumed true because reasoning about the intersection of a negated range and a non-negated range is complex. Otherwise, check `cidr_contains` in both directions: if A contains B or B contains A, they overlap.
**`ports_overlap`**: If either port list is empty, overlap is true (no port filter means all ports). Otherwise, check every pair from the two lists. Two port ranges overlap when `a.start <= b.effective_end() && b.start <= a.effective_end()`. This is the standard interval overlap test: two intervals [a,b] and [c,d] overlap when a <= d and c <= b.
---
## Optimization Suggestions (optimizer.v)
`src/analyzer/optimizer.v`
**`suggest_optimizations`**: Groups rules by chain and runs seven checks per chain (`find_mergeable_ports`, `suggest_reordering`, `find_missing_rate_limits`, `find_missing_conntrack`, `find_unreachable_after_drop`, `find_overly_permissive`, `find_redundant_terminal_drop`), plus one global check (`find_missing_logging`).
**`find_mergeable_ports`**: Groups single-port rules by a composite key of `protocol|source|destination|action`. Rules with identical keys differ only in destination port and could be merged into one multiport rule. For example, three separate rules accepting TCP on ports 80, 443, and 8080 from the same source would share the key `tcp|*|*|accept` and could become one rule with `--dports 80,443,8080`.
Groups with 2-15 rules are flagged. The upper bound of 15 comes from `config.multiport_max`, which is the iptables multiport extension limit. The kernel's `xt_multiport` module supports at most 15 ports per rule. Groups larger than 15 cannot use multiport and are skipped.
The function only considers rules with exactly one destination port (`rule.criteria.dst_ports.len != 1`). Rules with port ranges or multiple ports are already using some form of multi-matching and are not candidates for further merging.
**`find_missing_rate_limits`**: Two passes. First pass: build a map of all ports that already have rate limiting somewhere in the chain. This prevents false positives when a dedicated rate-limit rule exists alongside a plain accept rule for the same port. Second pass: check SSH rules (port 22) that ACCEPT without rate limiting. SSH is the only port checked because it is the primary brute-force target on internet-facing servers.
**`find_missing_conntrack`**: Scans for a rule with `.has(.established)` in its states. Three outcomes: no conntrack rule exists and the chain has more than 2 rules (WARNING), conntrack exists but at position 3 or later in the chain (INFO, because it works but costs unnecessary cycles), or conntrack is in the first few positions (no finding). The ESTABLISHED/RELATED rule is the single highest-impact performance optimization in any firewall. On a busy server, the vast majority of packets belong to existing connections. Without early conntrack, every one of those packets traverses the entire chain.
**`suggest_reordering`**: Skips the first three rules in each chain since those are typically loopback, conntrack, and invalid-drop rules that should stay at the top. For remaining rules, if a rule matches a high-traffic port (HTTP 80, HTTPS 443, DNS 53 from `config`) and is an ACCEPT, it suggests moving it earlier. The rationale: netfilter evaluates rules sequentially, so putting frequently-hit rules near the top reduces the average number of comparisons per packet.
**`find_missing_logging`**: Global check, not per-chain. For each chain with a DROP or REJECT default policy, it checks if any LOG rule exists in that chain. Without logging, dropped packets disappear silently and diagnosing connectivity issues becomes guesswork.
---
## Hardened Generation (generator.v)
`src/generator/generator.v`
**`generate_hardened`**: Dispatches to `generate_iptables_hardened` or `generate_nftables_hardened` based on the requested format. Both generators follow the same template order.
### generate_iptables_hardened
Builds a complete iptables-save format ruleset as an array of strings joined with newlines. The template order is not arbitrary. It reflects the packet processing priority of a production firewall, where early rules handle the highest-volume traffic and later rules handle progressively rarer cases:
1. **Default deny**: `:INPUT DROP [0:0]`, `:FORWARD DROP [0:0]`, `:OUTPUT ACCEPT [0:0]`. Whitelisting approach: deny everything, then allow only what is needed.
2. **Loopback**: Allow all traffic on the `lo` interface. Blocking loopback breaks most applications that communicate internally.
3. **Conntrack**: Accept ESTABLISHED/RELATED (packets belonging to existing connections) and drop INVALID. Placed early for performance.
4. **Anti-spoofing**: Iterates `config.private_ranges` and drops packets from RFC 1918 addresses arriving on the public interface. A packet claiming to be from `10.0.0.0/8` on your internet-facing `eth0` is spoofed.
5. **ICMP**: Rate-limited echo-request at `config.icmp_rate_limit` (1/second). Echo-reply, destination-unreachable, and time-exceeded are always allowed since they are essential for path MTU discovery and traceroute.
6. **Services**: Per-service rules driven by `config.service_ports`. SSH gets conntrack NEW state and rate limiting at `config.ssh_rate_limit`. DNS gets both TCP and UDP because zone transfers use TCP. NTP gets UDP only. Everything else defaults to TCP.
7. **Logging**: Rate-limited LOG with `config.log_prefix_dropped` before the final drop. Rate limiting prevents log flooding during a DDoS.
8. **Final DROP**: Explicit drop as a safety net even though the chain policy is already DROP. Defense in depth.
### generate_nftables_hardened
Same template in nftables syntax with `table inet filter { chain input { ... } }` block nesting. Syntactic differences: `ct state established,related accept` instead of `-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`. `ip saddr 10.0.0.0/8 drop` instead of `-s 10.0.0.0/8 -j DROP`. `limit rate 3/minute burst 5 packets` instead of `-m limit --limit 3/minute --limit-burst 5`. Also includes `chain forward` and `chain output` declarations.
### Format Export
**`export_ruleset`**: Dispatches to `export_as_iptables` or `export_as_nftables`.
**`export_as_iptables`**: Iterates all rules, tracking seen tables in the `tables_seen` map. When a new table appears, the function emits a `COMMIT` for the previous table (if any), then `*tablename`, then all chain policies. Each rule is converted by `rule_to_iptables` and appended. A final `COMMIT` is emitted after all rules. This handles multi-table rulesets: a ruleset with filter and nat rules produces two `*filter ... COMMIT` and `*nat ... COMMIT` blocks.
**`export_as_nftables`**: Groups chains by table using a `table_chains` map of type `map[string]map[string][]int`. The first pass iterates all rules and populates this map, grouping rule indices by table name and chain name:
```v
mut table_chains := map[string]map[string][]int{}
for i, rule in rs.rules {
tbl := rule.table.str()
if tbl !in table_chains {
table_chains[tbl] = map[string][]int{}
}
table_chains[tbl][rule.chain] << i
}
```
The second pass iterates the `table_chains` map. For each table, it emits `table inet X {`. For each chain within that table, it looks up the policy and emits the chain block. Standard chains (INPUT, OUTPUT, FORWARD, PREROUTING, POSTROUTING) get hook declarations with `type filter hook X priority 0; policy Y;`. The hook name is derived from the chain name by a match statement that maps `INPUT` to `input`, `OUTPUT` to `output`, etc. Custom chains get no hook declaration since the match returns an empty string, and the empty string check skips the hook line. Each rule index is then converted via `rule_to_nftables` and emitted inside the chain block.
This table-grouped approach produces correct nftables output even when the source ruleset has rules from multiple tables interleaved, because the grouping map collects all chains per table before any output is generated.
### Rule Conversion Functions
**`rule_to_iptables`**: Builds an iptables rule string by conditionally appending parts to a `[]string` array that is joined with spaces. The emission order follows iptables convention:
1. Chain: `-A ${r.chain}` (always present)
2. Protocol: `-p ${r.criteria.protocol.str()}` (skipped when `.all`)
3. Source: `-s` or `! -s` with `address/cidr` (only if source is not `none`)
4. Destination: `-d` or `! -d` (only if destination is not `none`)
5. Interfaces: `-i ${iface}` and/or `-o ${oface}` (only if present)
6. Conntrack: `-m conntrack --ctstate NEW,ESTABLISHED,RELATED,INVALID` built by checking each flag bit individually
7. Ports: `--dport X` for single port, `-m multiport --dports X,Y,Z` for multiple
8. Rate limit: `-m limit --limit X --limit-burst Y` (only if limit_rate is set)
9. Action: `-j ${r.action.str()}` followed by any `target_args`
The conntrack state reconstruction is the inverse of `parse_conn_states`: it checks each flag with `.has()` and builds an array of state name strings, then joins them with commas. This round-trips correctly through parse -> model -> emit.
**`rule_to_nftables`**: Same conditional emission pattern, different syntax. The emission order follows nftables convention:
1. Interfaces: `iifname "X"` and/or `oifname "X"` with quoted values
2. Source: `ip saddr [!= ]address/cidr` using `!=` for negation instead of `!`
3. Destination: `ip daddr [!= ]address/cidr`
4. Conntrack: `ct state new,established,related,invalid` (lowercase, comma-separated)
5. Protocol and ports combined: `tcp dport 443` for single, `tcp dport { 80, 443 }` for set syntax, `ip protocol tcp` when protocol is specified but no ports
6. Rate limit: `limit rate X` inline
7. Action: lowercase keyword (`accept`, `drop`, `reject`); `log` with optional `prefix` argument handled specially
The key structural difference from `rule_to_iptables` is step 5: in nftables, protocol and port are a single expression. When there is a protocol but no ports, the rule uses `ip protocol tcp`. When there are ports, the protocol name is part of the port expression: `tcp dport 80`. This is handled by a three-way `if/else if/else` block in the protocol emission logic.
---
## Display Module (display.v)
`src/display/display.v`
The display module is the only module that depends on V's `term` package for colored output. It never touches parser, analyzer, or generator internals. It reads `models` types and `config` constants only.
**`print_banner`**: Draws a box using Unicode box-drawing characters with `term.bold()` and `term.cyan()`. Shows the app name and `config.version`. The banner uses the `term.dim()` function for lower-contrast text like the version number and subtitle.
**`print_rule_table`**: Builds a fixed-width table using column constants from config (`col_num = 5`, `col_chain = 12`, `col_proto = 8`, `col_source = 22`, `col_dest = 22`, `col_ports = 16`, `col_action = 12`). The header line concatenates `pad_right` calls for each column label. A separator line of dashes repeats to the total width. Each rule gets one row with its 1-based index, chain name, protocol, source, destination, ports, and colorized action.
The `format_addr` function unwraps the option type: if the address is `none` (no source or destination constraint), it returns `'*'`. Otherwise it calls `truncate` to fit the address within the column width. The `format_ports` function does the same for port lists, joining multiple ports with commas before truncation. The `colorize_action` function maps ACCEPT to `term.green()`, DROP and REJECT to `term.red()`, and LOG to `term.yellow()`. Other actions are not colorized.
**`print_finding`**: Renders a single finding. The severity badge is wrapped in brackets and colored via `colorize_severity`: `[CRITICAL]` in bold red, `[WARNING]` in yellow, `[INFO]` in cyan. The title is bolded. Affected rule numbers are converted from 0-indexed (internal representation) to 1-indexed (user-facing) by `.map('${it + 1}')`. The suggestion line uses `config.sym_arrow` (a Unicode right arrow) as a prefix and `term.green()` for the text, making actionable advice visually distinct from descriptive text.
**`print_findings`**: First counts findings by severity category (critical, warning, info), then prints a summary header with color-coded counts, then iterates each finding and prints it. If no findings exist, prints `config.sym_check` (a Unicode checkmark) in green with "No issues found".
**`print_summary`**: Shows the ruleset format, total rule count, chain count, and per-chain breakdown. Each chain entry shows its rule count and colorized default policy. The policy lookup uses `if p := rs.policies[chain_name]` to handle chains without explicit policies (which get a dimmed dash character).
**`print_diff`**: Compares two rulesets by converting each to a set of canonical strings. The `build_rule_set` function iterates all rules and creates a `map[string]bool` using `Rule.str()` as the key. The canonical form is tab-separated `chain\tprotocol\tsource\tdest\tports\taction`, which normalizes both iptables and nftables rules to the same representation. Set differences are computed by iterating each map and checking membership in the other. Rules unique to left get a red `-` prefix. Rules unique to right get a green `+` prefix. Identical rulesets print "Rulesets are equivalent" with a green checkmark.
**Utility functions**: `pad_right` right-pads a string with spaces to a target width. If the string is already at or beyond the target width, it returns unchanged. `truncate` cuts strings that exceed a maximum length and appends `...` as an ellipsis indicator. For maximum lengths of 3 or less, it skips the ellipsis and just truncates, since there would not be room for both content and the ellipsis marker.
---
## Testing
V test conventions: files end in `_test.v`, functions are prefixed with `test_`, and assertions use the `assert` keyword. No test framework is needed.
**Module-internal access.** Test files declare themselves in the same module as the code they test. `src/parser/parser_test.v` uses `module parser`, giving it access to private functions like `tokenize_iptables`. `src/analyzer/analyzer_test.v` uses `module analyzer` to directly call `find_shadowed_rules`, `match_is_superset`, `ports_overlap`, and other unexported functions. This is the same pattern as Go's `_test.go` files in the same package.
**Testdata files.** Tests use `@VMODROOT`, a compile-time constant that resolves to the directory containing `v.mod` (the project root). For example, `os.read_file(@VMODROOT + '/testdata/iptables_basic.rules')` loads fixture files with a path that works regardless of the current working directory.
**Running tests.** `v test src/` from the project root discovers and runs all `_test.v` files in parallel. The Justfile wraps this as `just test`. Individual test output with timing uses `just test-verbose`, which passes the `-stats` flag. Tests are designed to be fast: no external dependencies, no network calls, no file creation. Everything reads from the testdata directory or constructs structs inline.
**Test isolation.** Each test function constructs its own data and makes assertions. There is no shared test state, no setup/teardown, and no test ordering dependencies. A test like `test_find_shadowed_rule` in `analyzer_test.v` creates two `Rule` structs with specific criteria, calls the private `find_shadowed_rules` function directly, and asserts the result has the expected severity and title.
**Parser tests** (`src/parser/parser_test.v`): Organized in three tiers.
The first tier is unit tests for each shared parse function. `parse_network_addr` is tested with plain IP (asserts `cidr == 32`), CIDR /8, CIDR /24, and negated (asserts `negated == true`). `parse_port_spec` covers single port, range (asserts `start == 1024` and `end == 65535`), and negated. `parse_port_list` tests comma-separated parsing with and without spaces. `parse_protocol` verifies name parsing (tcp, udp, icmp), IANA number parsing (`'6'` -> tcp, `'17'` -> udp), and case insensitivity (`'TCP'` -> tcp). `parse_action` covers ACCEPT, DROP, REJECT, LOG, MASQUERADE. `parse_table` covers filter, nat, mangle. `parse_chain_type` checks standard types and the custom fallback. `parse_conn_states` verifies single state, multiple states, all four states, and case-insensitive input.
The second tier is format detection and tokenizer tests. `detect_format` is tested with iptables table headers, chain policies, rule lines, nftables blocks, and inputs starting with comments (verifies the comment is skipped). The tokenizer gets three tests: basic splitting (8 tokens from a simple rule), double-quoted strings with embedded spaces (verifies `"DROPPED: "` becomes `DROPPED: ` with space preserved and quotes stripped), and single-quoted strings (verifies `'my rule'` becomes `my rule`).
The third tier is integration tests loading real testdata files. `test_parse_iptables_basic_rule_count` loads `iptables_basic.rules` and asserts 9 rules. `test_parse_iptables_basic_policies` checks that INPUT is DROP, FORWARD is DROP, OUTPUT is ACCEPT. `test_parse_iptables_basic_ssh_rule` verifies the SSH rule has protocol TCP, one destination port of 22, and action ACCEPT. `test_parse_iptables_basic_conntrack` checks that the conntrack rule has both `.established` and `.related` states set. `test_parse_nftables_basic_rule_count` loads `nftables_basic.rules` and asserts 8 rules. Complex file tests verify multiport rules from `iptables_complex.rules`, rate limit extraction, and NAT table MASQUERADE rules.
**Analyzer tests** (`src/analyzer/analyzer_test.v`): The most comprehensive test file, organized into detection tests, helper function tests, and optimizer tests.
Detection tests use manually constructed `Rule` structs to test each pass in isolation. `test_find_shadowed_rule` creates a broad TCP rule (no port filter) and a narrow TCP/port-80 rule, calls `find_shadowed_rules` directly, and asserts one CRITICAL finding with "Shadowed" in the title. `test_find_contradiction` creates two rules with overlapping criteria (TCP port 80 from 192.168.1.0/24 with ACCEPT vs TCP port 80 to 10.0.0.0/8 with DROP), asserts one WARNING finding. `test_find_duplicate` passes the same rule twice, asserts one WARNING. `test_find_redundant` uses a broad TCP/ACCEPT and narrow TCP-port-80/ACCEPT, asserts one INFO finding. `test_no_false_positives_disjoint_rules` creates TCP/22 and UDP/53 rules, runs `analyze_conflicts` on a full `Ruleset`, and asserts no CRITICAL findings. This test is important because it validates that rules on different protocols are not falsely flagged.
Helper function tests exercise the building blocks. `matches_overlap` is tested with: same protocol and port (overlaps), different protocols (does not overlap), `.all` protocol (overlaps with anything), non-overlapping ports (tcp/80 vs tcp/443), empty port list matching everything. `match_is_superset` is tested with: broader criteria covering narrower, narrower criteria not covering broader, `.all` protocol as superset of specific, CIDR containment via 10.0.0.0/8 containing 10.1.2.0/24, CIDR non-containment via 10.0.0.0/24 not containing 172.16.0.0/24.
`criteria_equal` tests: identical criteria, different ports, different protocol, matching addresses, `none` vs `some` address. `actions_conflict` tests: accept/drop (true), accept/reject (true), drop/accept (true), accept/accept (false), drop/drop (false), drop/reject (false). Port helpers: `ports_overlap` for same port, different ports, range containing single, empty lists. `ports_is_superset` for empty outer (superset), empty inner (not superset), range covering single, single not covering other. Address helpers: `addr_is_superset` with broader CIDR, `none` outer (always superset), `none` inner (not superset), both `none` (true).
Optimizer tests: `test_find_mergeable_ports` creates three TCP rules on ports 80, 443, 8080 with same protocol/source/dest/action, asserts one finding suggesting merge. `test_find_missing_rate_limits_ssh` creates one TCP/22 ACCEPT rule without rate limiting, asserts one WARNING finding. `test_find_missing_conntrack_empty` passes empty inputs, asserts no findings (edge case guard). `test_opt_str_equal_*` tests verify the optional string equality helper with both-none, same-value, different-value, and one-none cases.
**Generator tests** (`src/generator/generator_test.v`): Tests the hardened template in both formats. The testing strategy for generators is string containment: generate the output and assert that specific substrings appear. This verifies that the right rules are present without being brittle to exact whitespace or ordering within sections.
iptables hardened tests verify: default deny policies (checks for `:INPUT DROP` and `:FORWARD DROP`), loopback rules (checks for `-A INPUT -i lo -j ACCEPT`), conntrack (checks for `--ctstate ESTABLISHED,RELATED -j ACCEPT` and `--ctstate INVALID -j DROP`), SSH with rate limit (checks that `--dport 22`, `--limit 3/minute`, and `--limit-burst 5` all appear), HTTP/HTTPS ports, anti-spoofing for all three RFC 1918 ranges (individually checks `-s 10.0.0.0/8`, `-s 172.16.0.0/12`, `-s 192.168.0.0/16`), ICMP rate limiting, logging prefix, final DROP as the last INPUT rule (scans for the last `-A INPUT` line and asserts it equals `-A INPUT -j DROP`), COMMIT markers, DNS dual-protocol (checks both `-p tcp --dport 53` and `-p udp --dport 53`), NTP UDP-only (checks `-p udp --dport 123`), and custom interface name (passes `'ens192'` and checks for `-i ens192`).
nftables hardened tests verify: table/chain structure, conntrack syntax (checks `ct state established,related accept`), SSH rate limit, anti-spoofing (checks `ip saddr 10.0.0.0/8 drop`), loopback (checks `iifname "lo" accept`), DNS dual-protocol.
`rule_to_iptables` unit tests: Each test constructs a `Rule` struct with specific criteria and asserts the output string contains the expected flags. TCP port test checks for `-A INPUT`, `-p tcp`, `--dport 80`, `-j ACCEPT`. Negated source test checks for `! -s 10.0.0.0/8`. Multiport test checks for `-m multiport --dports`.
`rule_to_nftables` unit tests: Same pattern with nftables syntax. Negated source checks for `!= 10.0.0.0/8` (nftables negation syntax). Multiport set checks for `tcp dport {` with both port numbers present. Log with prefix checks that `log prefix "DROPPED: "` appears as a single expression.
Export integration tests: `test_export_ruleset_iptables` creates a minimal `Ruleset` with one rule and one policy, exports it, and checks the output contains `*filter`, `:INPUT DROP`, `-A INPUT`, and `COMMIT`. `test_export_ruleset_nftables` does the same and checks for `table inet filter`, `chain input`, `tcp dport 80`. `test_export_empty_ruleset` verifies that exporting an empty ruleset does not crash.

View File

@ -0,0 +1,340 @@
<!-- © AngelaMos | 2026 | 04-CHALLENGES.md -->
# Extension Challenges
These challenges build on the existing `fwrule` codebase. Each one references specific files and functions so you know where to start. They assume you have read `models.v`, the parsers, the analyzer, and the generator.
---
## Easy (1-2 hours each)
### 1. Add IPv6 Support to NetworkAddr
`ip_to_u32` in `src/models/models.v` converts dotted-quad strings to `u32`. IPv6 addresses are 128 bits, so this path fails entirely.
**What to do:**
- Add `ip_to_u128` in `models.v`. V has no native u128, so use two `u64` fields (upper and lower halves).
- Add `cidr_contains_v6` with the same shift-and-compare logic on the u64 pair. Shifts under 64 only touch the upper half. Shifts 64-128 need both.
- Update `cidr_contains` to check for `:` (IPv6) vs `.` (IPv4) and dispatch accordingly.
- In `parse_network_addr` (`common.v`), detect `:` and default `cidr` to 128 instead of 32. Constants `cidr_max_v4` and `cidr_max_v6` already exist in `config.v`.
- Update `NetworkAddr.str()` to omit the prefix when it equals the default for that address family.
**Files to modify:** `src/models/models.v`, `src/parser/common.v`, `src/analyzer/conflict.v`
**Verify with:**
```
2001:db8::/32 contains 2001:db8:1::/48 => true
2001:db8::/32 contains 2001:db9::/32 => false
::1/128 contains ::1/128 => true
fe80::/10 contains 2001:db8::/32 => false
```
Gotcha: `::1` expands to `0:0:0:0:0:0:0:1`. Your parser needs to handle `::` shorthand.
---
### 2. Add a `stats` Command
Add a `stats` command to the CLI that reads a ruleset and prints: total rule count, rules per chain, rules per table, protocol distribution, and the top 5 destination ports.
**What to do:**
- Add `'stats'` to the subcommand match in `main.v`, pointing to a new `cmd_stats` function.
- Load the ruleset via `load_ruleset(args[0])`, iterate `rs.rules`, count with `map[string]int` for each dimension.
**Files to modify:** `src/main.v`
**Expected output for `fwrule stats testdata/iptables_complex.rules`:**
```
Ruleset Statistics
Total rules: 24
Format: iptables
Rules by chain:
INPUT 14
FORWARD 4
OUTPUT 3
Protocol distribution:
tcp 16
udp 4
icmp 2
```
---
### 3. Support `--output` Flag for File Export
`cmd_export` and `cmd_harden` print to stdout. Add a `-o` / `--output` flag that writes to a file instead.
**What to do:**
- Both functions already use `flag.new_flag_parser`. Add: `output_path := fp.string('output', \`o\`, '', 'write to file instead of stdout')`
- After generating the output string, check `output_path`. Non-empty means `os.write_file(output_path, output)`. Empty means `println(output)` as before.
- Handle write errors with stderr and `config.exit_file_error`.
**Files to modify:** `src/main.v`
**Test:** `fwrule harden -s ssh,http -f nftables -o hardened.rules && cat hardened.rules`
---
### 4. Add SNAT/DNAT Rule Parsing
The `-j` handler in `parse_iptables_rule` in `iptables.v` sweeps everything after the action into `target_args` as a raw string. When the action is SNAT or DNAT, `--to-source` or `--to-destination` arguments carry the translated address, but that address never gets parsed into structured data.
The model already has `.snat` and `.dnat` in the `Action` enum and `parse_action` maps them correctly.
**What to do:**
- Add `nat_target ?NetworkAddr` to `Rule` in `models.v`.
- In the `-j` handler, when action is `.snat` or `.dnat`, scan tokens for `--to-source` / `--to-destination` and parse with `parse_network_addr`. Handle the `address:port` format by splitting on `:` first.
- Update `rule_to_iptables` in `generator.v` to emit the NAT target arguments.
**Files to modify:** `src/models/models.v`, `src/parser/iptables.v`, `src/generator/generator.v`
**Test input:**
```
*nat
:PREROUTING ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -o eth0 -j SNAT --to-source 203.0.113.1
-A PREROUTING -i eth0 -p tcp --dport 80 -j DNAT --to-destination 10.0.0.5:8080
COMMIT
```
Parse and re-export. The `--to-source` and `--to-destination` arguments should round-trip correctly.
---
## Intermediate (3-6 hours each)
### 5. Live System Import
Run `iptables-save` or `nft list ruleset` as a subprocess and parse the output directly instead of reading a file.
V's `os.execute()` returns an `os.Result` with `exit_code` and `output`. That output string is what the existing parsers expect.
**What to do:**
- Add `--live` flag to `cmd_analyze` in `main.v`. When set, skip the file argument.
- Try `iptables-save` first. If exit code is 0, feed `result.output` to `parse_iptables()`. If it fails, try `nft list ruleset` and feed to `parse_nftables()`. If both fail, print a message about needing root or `CAP_NET_ADMIN`.
**Files to modify:** `src/main.v`, optionally `src/parser/common.v`
**Usage:** `sudo fwrule analyze --live`
---
### 6. UFW/firewalld Parsing
Parse UFW status output or firewalld zone XML into the same `Ruleset` model.
**UFW:** Lines like `22/tcp ALLOW IN Anywhere` map to INPUT chain ACCEPT rules. Detect UFW format by checking if the first non-empty line starts with `Status:`. Create `src/parser/ufw.v`.
**firewalld:** Zone XML in `/etc/firewalld/zones/` contains `<service name="ssh"/>` and `<port protocol="tcp" port="443"/>`. Map services to ports via `config.service_ports`. Use V's `encoding.xml`. Create `src/parser/firewalld.v`.
**Files to create:** `src/parser/ufw.v` and/or `src/parser/firewalld.v`
**Files to modify:** `src/parser/common.v` (extend `detect_format`)
---
### 7. Rule Dependency Graph
Build a directed graph where nodes are rules and edges are dependencies. Output DOT format for Graphviz.
**Dependency types to detect:**
- **Conntrack**: any rule with `.new_conn` state depends on the ESTABLISHED,RELATED rule
- **Log-action pair**: LOG at position `i` pairs with DROP/REJECT at `i+1`
- **NAT-filter**: DNAT in PREROUTING depends on ACCEPT in INPUT for the translated port
**Expected DOT output:**
```dot
digraph rules {
rankdir=TB;
node [shape=box, style=rounded];
r7 [label="7: ACCEPT tcp/22 NEW"];
r1 [label="1: ACCEPT ESTABLISHED,RELATED"];
r7 -> r1 [label="conntrack"];
r11 [label="11: LOG prefix DROPPED"];
r12 [label="12: DROP"];
r11 -> r12 [label="log-action"];
}
```
**Files to create:** `src/graph/graph.v`
**Files to modify:** `src/main.v` (add `graph` subcommand)
---
### 8. Automated Fix Application
The analyzer suggests fixes as text. Build a fixer that applies them to the actual ruleset.
**Three operations:**
1. **Remove duplicates**: delete the second rule from "Duplicate rule" findings
2. **Reorder shadowed rules**: move the shadowed (more-specific) rule before the shadowing (less-specific) one
3. **Insert missing conntrack**: add an ESTABLISHED,RELATED ACCEPT at position 1 when flagged
The key difficulty: fixes interact. Removing rule 5 shifts rules 6+ down by one, invalidating indices in other findings. Process fixes from highest index to lowest.
**What to do:**
- Create `src/fixer/fixer.v` with `pub fn apply_fixes(rs Ruleset, findings []Finding) Ruleset`
- Add `fix` subcommand in `main.v` that runs analysis, applies fixes, and outputs the patched ruleset via the generator
**Files to create:** `src/fixer/fixer.v`
**Files to modify:** `src/main.v`
**Verify:** `fwrule fix testdata/iptables_conflicts.rules -f iptables | fwrule analyze /dev/stdin` should show fewer findings.
---
## Advanced (1-2 days each)
### 9. Rule Coverage Analysis
Given test packets, trace which rules each packet matches and identify dead rules.
Define a `Packet` struct (`src_ip`, `dst_ip`, `protocol`, `dst_port`). Parse a file with one packet per line. Walk the chain for each packet using `cidr_contains` for IPs and `port_range_contains` for ports. First matching terminal action wins (same as netfilter). Track hit counts per rule.
**Test packet file:**
```
192.168.1.100 10.0.0.5 tcp 443
10.0.0.1 10.0.0.5 tcp 22
203.0.113.50 10.0.0.5 tcp 3306
```
**Output:** Per-rule hit counts, list of dead rules (zero hits), accept/drop breakdown.
**Files to create:** `src/simulator/simulator.v`
**Files to modify:** `src/main.v` (add `simulate` subcommand)
---
### 10. Temporal Rule Analysis
Compare two ruleset versions and classify each change by security impact: exposure increase (new ACCEPT or removed DROP), exposure decrease (new DROP or removed ACCEPT), rate limit change, or policy change.
Diff at the semantic level using `criteria_equal` from `conflict.v` for matching, not string comparison. A policy change from DROP to ACCEPT on INPUT is always critical. Adding an ACCEPT for a new port is an exposure increase.
**Example output:**
```
EXPOSURE INCREASE: Port 3306 (mysql) now accessible from 0.0.0.0/0
EXPOSURE DECREASE: SSH is now rate-limited
POLICY CHANGE: INPUT default changed from DROP to ACCEPT
Summary: 3 increase exposure, 1 decrease, 2 neutral
```
**Files to create:** `src/analyzer/audit.v`
**Files to modify:** `src/main.v` (add `audit` subcommand)
---
### 11. PCAP Replay Against Ruleset
Parse a PCAP file (24-byte global header, 16-byte per-packet header, then raw packet data). Extract Ethernet (14 bytes), IP (source/dest IP, protocol), and TCP/UDP (ports) headers. Reuse the matching logic from Challenge 9 to simulate rule hits. Start with IPv4 TCP/UDP only.
Two approaches: parse the binary directly in V, or use C interop with libpcap.
**Files to create:** `src/simulator/pcap.v`
**Files to modify:** `src/main.v` (add `replay` subcommand)
---
### 12. CIS Benchmark Compliance Check
Check a ruleset against CIS Benchmark controls for Linux firewalls:
| Control | Requirement | Detection |
|---------|-------------|-----------|
| 3.5.1.1 | Default deny on INPUT | `rs.policies["INPUT"]` is `.drop` or `.reject` |
| 3.5.1.2 | Default deny on FORWARD | `rs.policies["FORWARD"]` is `.drop` or `.reject` |
| 3.5.1.3 | Loopback allowed | Rule with `in_iface == "lo"` and `.accept` |
| 3.5.1.4 | Loopback source blocked | Rule blocking `127.0.0.0/8` on non-lo interfaces |
| 3.5.1.5 | Conntrack configured | ESTABLISHED,RELATED rule exists |
| 3.5.1.6 | Drop logging | LOG rule in chains with DROP policy |
**Files to create:** `src/analyzer/compliance.v`
**Files to modify:** `src/main.v` (add `compliance` subcommand)
---
## Expert (Multi-day projects)
### 13. Binary Decision Diagram (BDD) Conflict Detection
Replace the O(n^2) pairwise comparison in `conflict.v` with BDD-based analysis. Each bit of each packet field (88 BDD variables for IPv4: 32 src_ip + 32 dst_ip + 8 protocol + 16 dst_port) becomes a BDD variable. Each rule becomes a BDD that is true for matching packets. Shadowing: `B AND (NOT A)` is empty. Contradiction: intersection is non-empty with opposing actions.
Implement BDD operations in V (`bdd_var`, `bdd_and`, `bdd_or`, `bdd_not`, `bdd_is_empty`) or use C interop with BuDDy/CUDD.
**Research:** Al-Shaer and Hamed, "Discovery of Policy Anomalies in Distributed Firewalls" (2004).
**Files to create:** `src/bdd/bdd.v`, `src/analyzer/bdd_conflict.v`
---
### 14. Distributed Firewall Analysis
Analyze rulesets from multiple hosts to find path-level issues. Host A's OUTPUT allows port 3306 to Host B, but Host B's INPUT drops it. Neither host looks misconfigured alone.
Input format: `hostname interface_ip ruleset_file` per line. For each host pair, check OUTPUT-allows vs INPUT-allows reachability. Report per-pair, per-port results. Watch for NAT transforming addresses mid-path.
**Files to create:** `src/analyzer/distributed.v`, `src/topology/topology.v`
**Files to modify:** `src/main.v` (add `topology` subcommand)
---
### 15. Real-Time Rule Monitoring
Poll `iptables -L -v -n` at intervals (default 5s), parse packet/byte counters, compute deltas, and maintain a rolling 60-sample window. Alert when: a zero-hit rule starts getting hit, a rule exceeds 2x its rolling average, or a DROP rule accumulates hits rapidly.
```
[14:32:15] ALERT: Rule 5 (DROP tcp/3306) spike: 847 pkts/5s (avg: 2 pkts/5s)
[14:32:20] ALERT: Rule 3 (ACCEPT tcp/22) spike: 312 pkts/5s (avg: 8 pkts/5s)
```
**Files to create:** `src/monitor/monitor.v`
**Files to modify:** `src/main.v` (add `monitor` subcommand)
---
### 16. Formal Verification with SMT Solver
Encode rules as SMT bitvector constraints and use Z3 to prove properties like "no external traffic reaches port 3306." If UNSAT, the property holds. If SAT, Z3 gives you the specific packet that violates it.
Encode chain evaluation as nested if-then-else over rule match formulas. Use V's C interop for Z3's C API, or shell out with SMT-LIB2 input.
**Research:** Kazemian et al., "Header Space Analysis: Static Checking for Networks" (2012).
**Files to create:** `src/verifier/verifier.v`, `src/verifier/smt.v`
**Files to modify:** `src/main.v` (add `verify` subcommand)
---
## How to Approach These
- Pick one from each level as you progress
- Write tests first (at least 5 cases per challenge) in `src/<module>/<module>_test.v`
- Follow the existing pattern: models define data, parsers consume text, analyzers produce findings, generators produce text
- Run `v fmt -w src/` and `v test src/` after every change
- If a challenge feels too big, break it into pieces and test each piece independently

View File

@ -0,0 +1,778 @@
// ©AngelaMos | 2026
// analyzer_test.v
module analyzer
import src.models { MatchCriteria, NetworkAddr, PortSpec, Rule, Ruleset }
fn test_find_shadowed_rule() {
broad := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
}
}
narrow := Rule{
chain: 'INPUT'
action: .drop
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
}
findings := find_shadowed_rules([broad, narrow], [0, 1])
assert findings.len == 1
assert findings[0].severity == .critical
assert findings[0].title.contains('Shadowed')
}
fn test_find_contradiction() {
r1 := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
source: NetworkAddr{
address: '192.168.1.0'
cidr: 24
}
dst_ports: [PortSpec{
start: 80
}]
}
}
r2 := Rule{
chain: 'INPUT'
action: .drop
criteria: MatchCriteria{
protocol: .tcp
destination: NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
dst_ports: [PortSpec{
start: 80
}]
}
}
findings := find_contradictions([r1, r2], [0, 1])
assert findings.len == 1
assert findings[0].severity == .warning
assert findings[0].title.contains('Contradictory')
}
fn test_find_duplicate() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
}
findings := find_duplicates([r, r], [0, 1])
assert findings.len == 1
assert findings[0].severity == .warning
assert findings[0].title.contains('Duplicate')
}
fn test_find_redundant() {
broad := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
}
}
narrow := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
}
findings := find_redundant_rules([broad, narrow], [0, 1])
assert findings.len == 1
assert findings[0].severity == .info
assert findings[0].title.contains('Redundant')
}
fn test_no_false_positives_disjoint_rules() {
r1 := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 22
}]
}
}
r2 := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .udp
dst_ports: [PortSpec{
start: 53
}]
}
}
rs := Ruleset{
rules: [r1, r2]
source: .iptables
}
findings := analyze_conflicts(rs)
for f in findings {
assert f.severity != .critical
}
}
fn test_matches_overlap_same_protocol() {
a := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
b := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
assert matches_overlap(a, b)
}
fn test_matches_overlap_different_protocol() {
a := MatchCriteria{
protocol: .tcp
}
b := MatchCriteria{
protocol: .udp
}
assert !matches_overlap(a, b)
}
fn test_matches_overlap_all_protocol() {
a := MatchCriteria{
protocol: .all
}
b := MatchCriteria{
protocol: .tcp
}
assert matches_overlap(a, b)
}
fn test_matches_overlap_no_port_overlap() {
a := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
b := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 443
}]
}
assert !matches_overlap(a, b)
}
fn test_matches_overlap_empty_ports() {
a := MatchCriteria{
protocol: .tcp
}
b := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
assert matches_overlap(a, b)
}
fn test_match_is_superset_broader() {
outer := MatchCriteria{
protocol: .tcp
}
inner := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
assert match_is_superset(outer, inner)
}
fn test_match_is_superset_narrower_not_superset() {
outer := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
inner := MatchCriteria{
protocol: .tcp
}
assert !match_is_superset(outer, inner)
}
fn test_match_is_superset_all_protocol() {
outer := MatchCriteria{
protocol: .all
}
inner := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
assert match_is_superset(outer, inner)
}
fn test_match_is_superset_cidr_containment() {
outer := MatchCriteria{
source: NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
}
inner := MatchCriteria{
source: NetworkAddr{
address: '10.1.2.0'
cidr: 24
}
}
assert match_is_superset(outer, inner)
}
fn test_match_is_superset_cidr_not_contained() {
outer := MatchCriteria{
source: NetworkAddr{
address: '10.0.0.0'
cidr: 24
}
}
inner := MatchCriteria{
source: NetworkAddr{
address: '172.16.0.0'
cidr: 24
}
}
assert !match_is_superset(outer, inner)
}
fn test_criteria_equal_identical() {
a := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
b := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
assert criteria_equal(a, b)
}
fn test_criteria_equal_different_ports() {
a := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
b := MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 443
}]
}
assert !criteria_equal(a, b)
}
fn test_criteria_equal_different_protocol() {
a := MatchCriteria{
protocol: .tcp
}
b := MatchCriteria{
protocol: .udp
}
assert !criteria_equal(a, b)
}
fn test_criteria_equal_with_addresses() {
a := MatchCriteria{
source: NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
}
b := MatchCriteria{
source: NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
}
assert criteria_equal(a, b)
}
fn test_criteria_equal_none_vs_some() {
a := MatchCriteria{}
b := MatchCriteria{
source: NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
}
assert !criteria_equal(a, b)
}
fn test_actions_conflict_accept_drop() {
assert actions_conflict(.accept, .drop)
}
fn test_actions_conflict_accept_reject() {
assert actions_conflict(.accept, .reject)
}
fn test_actions_conflict_drop_accept() {
assert actions_conflict(.drop, .accept)
}
fn test_actions_no_conflict_same() {
assert !actions_conflict(.accept, .accept)
assert !actions_conflict(.drop, .drop)
}
fn test_actions_no_conflict_drop_reject() {
assert !actions_conflict(.drop, .reject)
}
fn test_ports_overlap_same_port() {
a := [PortSpec{
start: 80
}]
b := [PortSpec{
start: 80
}]
assert ports_overlap(a, b)
}
fn test_ports_overlap_different_ports() {
a := [PortSpec{
start: 80
}]
b := [PortSpec{
start: 443
}]
assert !ports_overlap(a, b)
}
fn test_ports_overlap_range_contains_single() {
a := [PortSpec{
start: 1
end: 1024
}]
b := [PortSpec{
start: 80
}]
assert ports_overlap(a, b)
}
fn test_ports_overlap_empty_matches_all() {
a := []PortSpec{}
b := [PortSpec{
start: 80
}]
assert ports_overlap(a, b)
}
fn test_ports_overlap_both_empty() {
a := []PortSpec{}
b := []PortSpec{}
assert ports_overlap(a, b)
}
fn test_ports_is_superset_empty_outer() {
assert ports_is_superset([]PortSpec{}, [PortSpec{ start: 80 }])
}
fn test_ports_is_superset_empty_inner() {
assert !ports_is_superset([PortSpec{ start: 80 }], []PortSpec{})
}
fn test_ports_is_superset_range() {
outer := [PortSpec{
start: 1
end: 1024
}]
inner := [PortSpec{
start: 80
}]
assert ports_is_superset(outer, inner)
}
fn test_ports_is_superset_not_contained() {
outer := [PortSpec{
start: 80
}]
inner := [PortSpec{
start: 443
}]
assert !ports_is_superset(outer, inner)
}
fn test_addr_is_superset_broader_cidr() {
outer := NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
inner := NetworkAddr{
address: '10.1.2.3'
cidr: 32
}
assert addr_is_superset(outer, inner)
}
fn test_addr_is_superset_none_outer() {
inner := NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
assert addr_is_superset(none, inner)
}
fn test_addr_is_superset_none_inner() {
outer := NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
assert !addr_is_superset(outer, none)
}
fn test_addr_is_superset_both_none() {
assert addr_is_superset(?NetworkAddr(none), ?NetworkAddr(none))
}
fn test_addrs_overlap_both_none() {
assert addrs_overlap(?NetworkAddr(none), ?NetworkAddr(none))
}
fn test_addrs_overlap_one_none() {
addr := NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
assert addrs_overlap(addr, ?NetworkAddr(none))
assert addrs_overlap(?NetworkAddr(none), addr)
}
fn test_addrs_overlap_contained() {
a := NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
b := NetworkAddr{
address: '10.1.0.0'
cidr: 16
}
assert addrs_overlap(a, b)
}
fn test_addrs_overlap_disjoint() {
a := NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
b := NetworkAddr{
address: '172.16.0.0'
cidr: 12
}
assert !addrs_overlap(a, b)
}
fn test_find_mergeable_ports() {
r1 := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
}
r2 := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 443
}]
}
}
r3 := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 8080
}]
}
}
findings := find_mergeable_ports([r1, r2, r3], [0, 1, 2])
assert findings.len == 1
assert findings[0].title == 'Mergeable port rules'
}
fn test_find_missing_rate_limits_ssh() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 22
}]
}
}
findings := find_missing_rate_limits([r], [0])
assert findings.len == 1
assert findings[0].severity == .warning
assert findings[0].title.contains('rate limit')
}
fn test_find_missing_conntrack_empty() {
findings := find_missing_conntrack([]Rule{}, []int{})
assert findings.len == 0
}
fn test_opt_str_equal_both_none() {
assert opt_str_equal(?string(none), ?string(none))
}
fn test_opt_str_equal_same() {
assert opt_str_equal('eth0', 'eth0')
}
fn test_opt_str_equal_different() {
assert !opt_str_equal('eth0', 'lo')
}
fn test_opt_str_equal_one_none() {
assert !opt_str_equal('eth0', ?string(none))
assert !opt_str_equal(?string(none), 'eth0')
}
fn test_find_shadowed_same_action_not_reported() {
broad := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
}
}
narrow := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
}
findings := find_shadowed_rules([broad, narrow], [0, 1])
assert findings.len == 0
}
fn test_find_shadowed_different_action_reported() {
broad := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
}
}
narrow := Rule{
chain: 'INPUT'
action: .drop
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
}
findings := find_shadowed_rules([broad, narrow], [0, 1])
assert findings.len == 1
assert findings[0].severity == .critical
assert findings[0].description.contains('DROP')
assert findings[0].description.contains('ACCEPT')
}
fn test_cidr_contains_slash_zero() {
outer := NetworkAddr{
address: '0.0.0.0'
cidr: 0
}
inner := NetworkAddr{
address: '192.168.1.1'
cidr: 32
}
assert models.cidr_contains(outer, inner)
}
fn test_cidr_contains_slash_zero_any_addr() {
outer := NetworkAddr{
address: '0.0.0.0'
cidr: 0
}
inner := NetworkAddr{
address: '10.255.0.1'
cidr: 24
}
assert models.cidr_contains(outer, inner)
}
fn test_find_unreachable_after_drop() {
catch_all := Rule{
chain: 'INPUT'
action: .drop
criteria: MatchCriteria{}
}
unreachable := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
}
findings := find_unreachable_after_drop([catch_all, unreachable], [0, 1])
assert findings.len == 1
assert findings[0].severity == .warning
assert findings[0].title.contains('Unreachable')
}
fn test_find_unreachable_no_catchall() {
r1 := Rule{
chain: 'INPUT'
action: .drop
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 22
}]
}
}
r2 := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
}
findings := find_unreachable_after_drop([r1, r2], [0, 1])
assert findings.len == 0
}
fn test_find_overly_permissive_ssh() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 22
}]
}
}
findings := find_overly_permissive([r], [0])
assert findings.len == 1
assert findings[0].severity == .warning
assert findings[0].title.contains('permissive')
}
fn test_find_overly_permissive_with_source() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
source: NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
dst_ports: [PortSpec{
start: 22
}]
}
}
findings := find_overly_permissive([r], [0])
assert findings.len == 0
}
fn test_find_overly_permissive_non_sensitive_port() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
}
findings := find_overly_permissive([r], [0])
assert findings.len == 0
}
fn test_find_redundant_terminal_drop() {
catch_all := Rule{
chain: 'INPUT'
action: .drop
criteria: MatchCriteria{}
}
policies := {
'INPUT': models.Action.drop
}
findings := find_redundant_terminal_drop([catch_all], [0], policies, 'INPUT')
assert findings.len == 1
assert findings[0].severity == .info
assert findings[0].title.contains('Redundant')
}
fn test_find_redundant_terminal_drop_accept_policy() {
catch_all := Rule{
chain: 'INPUT'
action: .drop
criteria: MatchCriteria{}
}
policies := {
'INPUT': models.Action.accept
}
findings := find_redundant_terminal_drop([catch_all], [0], policies, 'INPUT')
assert findings.len == 0
}

View File

@ -0,0 +1,315 @@
// ©AngelaMos | 2026
// conflict.v
module analyzer
import src.models {
Action,
Finding,
MatchCriteria,
NetworkAddr,
PortSpec,
Rule,
Ruleset,
}
pub fn analyze_conflicts(rs Ruleset) []Finding {
mut findings := []Finding{}
chains := rs.rules_by_chain()
for _, indices in chains {
rules := indices.map(rs.rules[it])
findings << find_duplicates(rules, indices)
findings << find_shadowed_rules(rules, indices)
findings << find_contradictions(rules, indices)
findings << find_redundant_rules(rules, indices)
}
return findings
}
fn find_shadowed_rules(rules []Rule, indices []int) []Finding {
mut findings := []Finding{}
for i := 0; i < rules.len; i++ {
for j := i + 1; j < rules.len; j++ {
if match_is_superset(rules[i].criteria, rules[j].criteria)
&& rules[i].action != rules[j].action {
findings << Finding{
severity: .critical
title: 'Shadowed rule detected'
description: 'Rule ${indices[j] + 1} (${rules[j].action.str()}) can never match because rule ${
indices[i] + 1} (${rules[i].action.str()}) catches all its traffic first'
rule_indices: [indices[i], indices[j]]
suggestion: 'Remove rule ${indices[j] + 1} or reorder it before rule ${
indices[i] + 1}'
}
}
}
}
return findings
}
fn find_contradictions(rules []Rule, indices []int) []Finding {
mut findings := []Finding{}
for i := 0; i < rules.len; i++ {
for j := i + 1; j < rules.len; j++ {
if matches_overlap(rules[i].criteria, rules[j].criteria)
&& actions_conflict(rules[i].action, rules[j].action) {
if match_is_superset(rules[i].criteria, rules[j].criteria) {
continue
}
findings << Finding{
severity: .warning
title: 'Contradictory rules'
description: 'Rules ${indices[i] + 1} (${rules[i].action.str()}) and ${
indices[j] + 1} (${rules[j].action.str()}) overlap but have opposing actions'
rule_indices: [indices[i], indices[j]]
suggestion: 'Review whether both rules are needed and clarify the intended behavior'
}
}
}
}
return findings
}
fn find_duplicates(rules []Rule, indices []int) []Finding {
mut findings := []Finding{}
for i := 0; i < rules.len; i++ {
for j := i + 1; j < rules.len; j++ {
if criteria_equal(rules[i].criteria, rules[j].criteria)
&& rules[i].action == rules[j].action {
findings << Finding{
severity: .warning
title: 'Duplicate rule'
description: 'Rules ${indices[i] + 1} and ${indices[j] + 1} have identical match criteria and action'
rule_indices: [indices[i], indices[j]]
suggestion: 'Remove rule ${indices[j] + 1}'
}
}
}
}
return findings
}
fn find_redundant_rules(rules []Rule, indices []int) []Finding {
mut findings := []Finding{}
for i := 0; i < rules.len; i++ {
for j := 0; j < rules.len; j++ {
if i == j {
continue
}
if rules[i].action == rules[j].action
&& match_is_superset(rules[i].criteria, rules[j].criteria)
&& !criteria_equal(rules[i].criteria, rules[j].criteria) {
if i < j {
findings << Finding{
severity: .info
title: 'Redundant rule'
description: 'Rule ${indices[j] + 1} is a subset of rule ${indices[i] + 1} with the same action'
rule_indices: [indices[i], indices[j]]
suggestion: 'Rule ${indices[j] + 1} can be safely removed'
}
}
}
}
}
return findings
}
fn matches_overlap(a MatchCriteria, b MatchCriteria) bool {
if a.protocol != .all && b.protocol != .all && a.protocol != b.protocol {
return false
}
if !addrs_overlap(a.source, b.source) {
return false
}
if !addrs_overlap(a.destination, b.destination) {
return false
}
if !ports_overlap(a.dst_ports, b.dst_ports) {
return false
}
if !iface_overlaps(a.in_iface, b.in_iface) {
return false
}
if !iface_overlaps(a.out_iface, b.out_iface) {
return false
}
return true
}
fn match_is_superset(outer MatchCriteria, inner MatchCriteria) bool {
if outer.protocol != .all && outer.protocol != inner.protocol {
return false
}
if !addr_is_superset(outer.source, inner.source) {
return false
}
if !addr_is_superset(outer.destination, inner.destination) {
return false
}
if !ports_is_superset(outer.dst_ports, inner.dst_ports) {
return false
}
if !ports_is_superset(outer.src_ports, inner.src_ports) {
return false
}
if !iface_is_superset(outer.in_iface, inner.in_iface) {
return false
}
if !iface_is_superset(outer.out_iface, inner.out_iface) {
return false
}
if !outer.states.is_empty() {
if inner.states.is_empty() || !outer.states.all(inner.states) {
return false
}
}
return true
}
fn criteria_equal(a MatchCriteria, b MatchCriteria) bool {
if a.protocol != b.protocol {
return false
}
if !addrs_equal(a.source, b.source) {
return false
}
if !addrs_equal(a.destination, b.destination) {
return false
}
if !ports_equal(a.dst_ports, b.dst_ports) {
return false
}
if !ports_equal(a.src_ports, b.src_ports) {
return false
}
if !opt_str_equal(a.in_iface, b.in_iface) {
return false
}
if !opt_str_equal(a.out_iface, b.out_iface) {
return false
}
if a.states != b.states {
return false
}
return true
}
fn actions_conflict(a Action, b Action) bool {
accept_like := [Action.accept]
deny_like := [Action.drop, Action.reject]
a_allows := a in accept_like
b_allows := b in accept_like
a_denies := a in deny_like
b_denies := b in deny_like
return (a_allows && b_denies) || (a_denies && b_allows)
}
fn addrs_overlap(a ?NetworkAddr, b ?NetworkAddr) bool {
a_val := a or { return true }
b_val := b or { return true }
if a_val.negated != b_val.negated {
return true
}
return models.cidr_contains(a_val, b_val) || models.cidr_contains(b_val, a_val)
}
fn addr_is_superset(outer ?NetworkAddr, inner ?NetworkAddr) bool {
if ov := outer {
if iv := inner {
return models.cidr_contains(ov, iv)
}
return false
}
return true
}
fn addrs_equal(a ?NetworkAddr, b ?NetworkAddr) bool {
if av := a {
if bv := b {
return av.address == bv.address && av.cidr == bv.cidr && av.negated == bv.negated
}
return false
}
if _ := b {
return false
}
return true
}
fn ports_overlap(a []PortSpec, b []PortSpec) bool {
if a.len == 0 || b.len == 0 {
return true
}
for pa in a {
for pb in b {
if pa.start <= pb.effective_end() && pb.start <= pa.effective_end() {
return true
}
}
}
return false
}
fn ports_is_superset(outer []PortSpec, inner []PortSpec) bool {
if outer.len == 0 {
return true
}
if inner.len == 0 {
return false
}
for ip in inner {
mut covered := false
for op in outer {
if models.port_range_contains(op, ip) {
covered = true
break
}
}
if !covered {
return false
}
}
return true
}
fn ports_equal(a []PortSpec, b []PortSpec) bool {
if a.len != b.len {
return false
}
for i, pa in a {
if pa.start != b[i].start || pa.effective_end() != b[i].effective_end()
|| pa.negated != b[i].negated {
return false
}
}
return true
}
fn iface_overlaps(a ?string, b ?string) bool {
a_val := a or { return true }
b_val := b or { return true }
return a_val == b_val
}
fn iface_is_superset(outer ?string, inner ?string) bool {
if ov := outer {
if iv := inner {
return ov == iv
}
return false
}
return true
}
fn opt_str_equal(a ?string, b ?string) bool {
if av := a {
if bv := b {
return av == bv
}
return false
}
if _ := b {
return false
}
return true
}

View File

@ -0,0 +1,253 @@
// ©AngelaMos | 2026
// optimizer.v
module analyzer
import src.config
import src.models { Finding, Rule, Ruleset }
pub fn suggest_optimizations(rs Ruleset) []Finding {
mut findings := []Finding{}
chains := rs.rules_by_chain()
for chain_name, indices in chains {
rules := indices.map(rs.rules[it])
findings << find_mergeable_ports(rules, indices)
findings << suggest_reordering(rules, indices)
findings << find_missing_rate_limits(rules, indices)
findings << find_missing_conntrack(rules, indices)
findings << find_unreachable_after_drop(rules, indices)
findings << find_overly_permissive(rules, indices)
findings << find_redundant_terminal_drop(rules, indices, rs.policies, chain_name)
}
findings << find_missing_logging(rs)
return findings
}
fn find_mergeable_ports(rules []Rule, indices []int) []Finding {
mut findings := []Finding{}
mut groups := map[string][][]int{}
for i, rule in rules {
if rule.criteria.dst_ports.len != 1 {
continue
}
key := '${rule.criteria.protocol}|${format_opt_addr(rule.criteria.source)}|${format_opt_addr(rule.criteria.destination)}|${rule.action}'
groups[key] << [indices[i], rule.criteria.dst_ports[0].start]
}
for _, entries in groups {
if entries.len < 2 {
continue
}
if entries.len > config.multiport_max {
continue
}
rule_indices := entries.map(it[0])
ports := entries.map('${it[1]}')
findings << Finding{
severity: .info
title: 'Mergeable port rules'
description: '${entries.len} rules could be combined into a single multiport rule'
rule_indices: rule_indices
suggestion: 'Merge into one rule with --dports ${ports.join(',')}'
}
}
return findings
}
fn suggest_reordering(rules []Rule, indices []int) []Finding {
mut findings := []Finding{}
high_traffic_ports := [config.port_http, config.port_https, config.port_dns]
for i, rule in rules {
if i < 3 {
continue
}
for dp in rule.criteria.dst_ports {
if dp.start in high_traffic_ports && rule.action == .accept {
findings << Finding{
severity: .info
title: 'Rule ordering optimization'
description: 'Rule ${indices[i] + 1} matches high-traffic port ${dp.start} but is at position ${
i + 1} in the chain'
rule_indices: [indices[i]]
suggestion: 'Move this rule earlier in the chain for better performance'
}
break
}
}
}
return findings
}
fn find_missing_rate_limits(rules []Rule, indices []int) []Finding {
mut findings := []Finding{}
mut rate_limited_ports := map[int]bool{}
for rule in rules {
if limit := rule.criteria.limit_rate {
_ = limit
for dp in rule.criteria.dst_ports {
rate_limited_ports[dp.start] = true
}
}
}
exposed_ports := [config.port_ssh]
for i, rule in rules {
if rule.action != .accept {
continue
}
for dp in rule.criteria.dst_ports {
if dp.start in exposed_ports && dp.start !in rate_limited_ports {
findings << Finding{
severity: .warning
title: 'Missing rate limit'
description: 'Port ${dp.start} is allowed without rate limiting'
rule_indices: [indices[i]]
suggestion: 'Add rate limiting (e.g., ${config.ssh_rate_limit} burst ${config.ssh_rate_burst}) for port ${dp.start}'
}
}
}
}
return findings
}
fn find_missing_logging(rs Ruleset) []Finding {
mut findings := []Finding{}
for chain_name, policy in rs.policies {
if policy == .drop || policy == .reject {
mut has_log := false
for rule in rs.rules {
if rule.chain == chain_name && rule.action == .log {
has_log = true
break
}
}
if !has_log {
findings << Finding{
severity: .info
title: 'Missing drop logging'
description: '${chain_name} chain has ${policy.str()} policy but no LOG rule'
rule_indices: []
suggestion: 'Add a LOG rule before the final drop to track rejected traffic'
}
}
}
}
return findings
}
fn find_missing_conntrack(rules []Rule, indices []int) []Finding {
mut findings := []Finding{}
if rules.len == 0 {
return findings
}
mut has_conntrack := false
mut conntrack_position := -1
for i, rule in rules {
if !rule.criteria.states.is_empty() {
if rule.criteria.states.has(.established) {
has_conntrack = true
conntrack_position = i
break
}
}
}
if !has_conntrack && rules.len > 2 {
findings << Finding{
severity: .warning
title: 'Missing connection tracking'
description: 'No ESTABLISHED/RELATED rule found in this chain'
rule_indices: []
suggestion: 'Add a conntrack rule early in the chain to allow established connections'
}
} else if has_conntrack && conntrack_position > 2 {
findings << Finding{
severity: .info
title: 'Late connection tracking rule'
description: 'ESTABLISHED/RELATED rule is at position ${conntrack_position + 1}, should be near the top'
rule_indices: [indices[conntrack_position]]
suggestion: 'Move the conntrack rule to position 1 or 2 in the chain for optimal performance'
}
}
return findings
}
fn find_unreachable_after_drop(rules []Rule, indices []int) []Finding {
mut findings := []Finding{}
for i := 0; i < rules.len; i++ {
is_catchall := (rules[i].action == .drop || rules[i].action == .reject)
&& rules[i].criteria.protocol == .all && rules[i].criteria.source == none
&& rules[i].criteria.destination == none && rules[i].criteria.dst_ports.len == 0
&& rules[i].criteria.src_ports.len == 0
if is_catchall && i + 1 < rules.len {
for j := i + 1; j < rules.len; j++ {
findings << Finding{
severity: .warning
title: 'Unreachable rule after catch-all drop'
description: 'Rule ${indices[j] + 1} appears after a catch-all ${rules[i].action.str()} at position ${
indices[i] + 1} and can never be reached'
rule_indices: [indices[i], indices[j]]
suggestion: 'Remove rule ${indices[j] + 1} or move it before the catch-all drop'
}
}
break
}
}
return findings
}
fn find_overly_permissive(rules []Rule, indices []int) []Finding {
mut findings := []Finding{}
sensitive_ports := [config.port_ssh, 3306, 5432, 6379]
for i, rule in rules {
if rule.action != .accept {
continue
}
if rule.criteria.source != none {
continue
}
for dp in rule.criteria.dst_ports {
if dp.start in sensitive_ports {
findings << Finding{
severity: .warning
title: 'Overly permissive source'
description: 'Rule ${indices[i] + 1} allows access to port ${dp.start} from any source address'
rule_indices: [indices[i]]
suggestion: 'Restrict the source address to trusted networks for port ${dp.start}'
}
break
}
}
}
return findings
}
fn find_redundant_terminal_drop(rules []Rule, indices []int, policies map[string]models.Action, chain_name string) []Finding {
mut findings := []Finding{}
if rules.len == 0 {
return findings
}
policy := policies[chain_name] or { return findings }
if policy != .drop && policy != .reject {
return findings
}
last := rules[rules.len - 1]
is_catchall_drop := (last.action == .drop || last.action == .reject)
&& last.criteria.protocol == .all && last.criteria.source == none
&& last.criteria.destination == none && last.criteria.dst_ports.len == 0
&& last.criteria.src_ports.len == 0
if is_catchall_drop {
findings << Finding{
severity: .info
title: 'Redundant terminal drop'
description: 'Rule ${indices[rules.len - 1] + 1} explicitly drops all traffic but the chain policy is already ${policy.str()}'
rule_indices: [indices[rules.len - 1]]
suggestion: 'Remove the explicit drop since the chain policy handles it'
}
}
return findings
}
fn format_opt_addr(addr ?models.NetworkAddr) string {
if a := addr {
return a.str()
}
return '*'
}

View File

@ -0,0 +1,99 @@
// ©AngelaMos | 2026
// config.v
module config
pub const version = '1.0.0'
pub const app_name = 'fwrule'
pub const exit_success = 0
pub const exit_parse_error = 1
pub const exit_file_error = 2
pub const exit_analysis_error = 3
pub const exit_usage_error = 64
pub const port_ssh = 22
pub const port_dns = 53
pub const port_http = 80
pub const port_https = 443
pub const port_smtp = 25
pub const port_ntp = 123
pub const cidr_max_v4 = 32
pub const cidr_max_v6 = 128
pub const private_ranges = [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
]
pub const loopback_v4 = '127.0.0.0/8'
pub const loopback_v6 = '::1/128'
pub const multiport_max = 15
pub const default_iface = 'eth0'
pub const default_services = ['ssh', 'http', 'https']
pub const ssh_rate_limit = '3/minute'
pub const ssh_rate_burst = 5
pub const icmp_rate_limit = '1/second'
pub const icmp_rate_burst = 5
pub const log_prefix_dropped = 'DROPPED: '
pub const log_prefix_rejected = 'REJECTED: '
pub const col_num = 5
pub const col_chain = 12
pub const col_proto = 8
pub const col_source = 22
pub const col_dest = 22
pub const col_ports = 16
pub const col_action = 12
pub const sym_check = '\u2713'
pub const sym_cross = '\u2717'
pub const sym_warn = '\u26A0'
pub const sym_arrow = '\u2192'
pub const sym_bullet = '\u2022'
pub const service_ports = {
'ssh': 22
'dns': 53
'http': 80
'https': 443
'smtp': 25
'ntp': 123
'ftp': 21
'mysql': 3306
'pg': 5432
'redis': 6379
}

View File

@ -0,0 +1,199 @@
// ©AngelaMos | 2026
// display.v
module display
import term
import src.config
import src.models { Action, Finding, Rule, Ruleset, Severity }
pub fn print_banner() {
banner := '
${term.bold(term.cyan(''))}
${term.bold(term.cyan('│'))} ${term.bold('FWRULE')} ${term.dim(
'v' + config.version)} ${term.bold(term.cyan('│'))}
${term.bold(term.cyan('│'))} ${term.dim('Firewall Rule Engine for iptables/nft')} ${term.bold(term.cyan('│'))}
${term.bold(term.cyan(''))}
'
println(banner)
}
pub fn print_rule_table(rs Ruleset) {
header := pad_right('#', config.col_num) + pad_right('Chain', config.col_chain) +
pad_right('Proto', config.col_proto) + pad_right('Source', config.col_source) +
pad_right('Dest', config.col_dest) + pad_right('Ports', config.col_ports) +
pad_right('Action', config.col_action)
println(term.bold(header))
println('${'─'.repeat(config.col_num + config.col_chain + config.col_proto +
config.col_source + config.col_dest + config.col_ports + config.col_action)}')
for i, rule in rs.rules {
num := pad_right('${i + 1}', config.col_num)
chain := pad_right(rule.chain, config.col_chain)
proto := pad_right(rule.criteria.protocol.str(), config.col_proto)
src := pad_right(format_addr(rule.criteria.source), config.col_source)
dst := pad_right(format_addr(rule.criteria.destination), config.col_dest)
ports := pad_right(format_ports(rule.criteria.dst_ports), config.col_ports)
action_str := colorize_action(rule.action)
println('${num}${chain}${proto}${src}${dst}${ports}${action_str}')
}
println('')
}
pub fn print_finding(f Finding) {
severity_str := colorize_severity(f.severity, f.severity.str())
println(' ${severity_str} ${term.bold(f.title)}')
println(' ${f.description}')
if f.rule_indices.len > 0 {
rule_nums := f.rule_indices.map('${it + 1}')
println(' ${term.dim('Rules:')} ${rule_nums.join(', ')}')
}
if f.suggestion.len > 0 {
println(' ${term.dim(config.sym_arrow)} ${term.green(f.suggestion)}')
}
println('')
}
pub fn print_findings(findings []Finding) {
if findings.len == 0 {
println(' ${term.green(config.sym_check)} No issues found')
return
}
mut criticals := 0
mut warnings := 0
mut infos := 0
for f in findings {
match f.severity {
.critical { criticals++ }
.warning { warnings++ }
.info { infos++ }
}
}
println(term.bold(' Findings: ${findings.len} total'))
if criticals > 0 {
println(' ${term.red('${criticals} critical')}')
}
if warnings > 0 {
println(' ${term.yellow('${warnings} warnings')}')
}
if infos > 0 {
println(' ${term.cyan('${infos} info')}')
}
println('')
for f in findings {
print_finding(f)
}
}
pub fn print_summary(rs Ruleset) {
println(term.bold(' Ruleset Summary'))
println(' ${term.dim('Format:')} ${rs.source.str()}')
println(' ${term.dim('Rules:')} ${rs.rules.len}')
chains := rs.rules_by_chain()
println(' ${term.dim('Chains:')} ${chains.len}')
for chain_name, indices in chains {
policy_str := if p := rs.policies[chain_name] {
colorize_action(p)
} else {
term.dim('-')
}
println(' ${chain_name}: ${indices.len} rules (policy: ${policy_str})')
}
println('')
}
pub fn print_diff(left Ruleset, right Ruleset) {
println(term.bold(' Ruleset Comparison'))
println(' ${term.dim('Left:')} ${left.source.str()} (${left.rules.len} rules)')
println(' ${term.dim('Right:')} ${right.source.str()} (${right.rules.len} rules)')
println('')
left_set := build_rule_set(left.rules)
right_set := build_rule_set(right.rules)
mut only_left := []string{}
mut only_right := []string{}
for key, _ in left_set {
if key !in right_set {
only_left << key
}
}
for key, _ in right_set {
if key !in left_set {
only_right << key
}
}
if only_left.len == 0 && only_right.len == 0 {
println(' ${term.green(config.sym_check)} Rulesets are equivalent')
return
}
if only_left.len > 0 {
println(term.bold(' Only in left:'))
for entry in only_left {
println(' ${term.red('- ' + entry)}')
}
}
if only_right.len > 0 {
println(term.bold(' Only in right:'))
for entry in only_right {
println(' ${term.green('+ ' + entry)}')
}
}
println('')
}
fn build_rule_set(rules []Rule) map[string]bool {
mut result := map[string]bool{}
for rule in rules {
result[rule.str()] = true
}
return result
}
fn format_addr(addr ?models.NetworkAddr) string {
if a := addr {
return truncate(a.str(), config.col_source - 2)
}
return '*'
}
fn format_ports(ports []models.PortSpec) string {
if ports.len == 0 {
return '*'
}
strs := ports.map(it.str())
return truncate(strs.join(','), config.col_ports - 2)
}
fn colorize_action(a Action) string {
return match a {
.accept { term.green(a.str()) }
.drop { term.red(a.str()) }
.reject { term.red(a.str()) }
.log { term.yellow(a.str()) }
else { a.str() }
}
}
fn colorize_severity(s Severity, text string) string {
return match s {
.critical { term.bold(term.red('[${text}]')) }
.warning { term.yellow('[${text}]') }
.info { term.cyan('[${text}]') }
}
}
fn pad_right(s string, width int) string {
if s.len >= width {
return s
}
return s + ' '.repeat(width - s.len)
}
fn truncate(s string, max_len int) string {
if s.len <= max_len {
return s
}
if max_len <= 3 {
return s[..max_len]
}
return s[..max_len - 3] + '...'
}

View File

@ -0,0 +1,300 @@
// ©AngelaMos | 2026
// generator.v
module generator
import src.config
import src.models { Rule, RuleSource, Ruleset }
pub fn generate_hardened(services []string, iface string, fmt RuleSource) string {
return match fmt {
.iptables { generate_iptables_hardened(services, iface) }
.nftables { generate_nftables_hardened(services, iface) }
}
}
fn generate_iptables_hardened(services []string, iface string) string {
mut lines := []string{}
lines << '*filter'
lines << ':INPUT DROP [0:0]'
lines << ':FORWARD DROP [0:0]'
lines << ':OUTPUT ACCEPT [0:0]'
lines << ''
lines << '-A INPUT -i lo -j ACCEPT'
lines << '-A OUTPUT -o lo -j ACCEPT'
lines << ''
lines << '-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT'
lines << '-A INPUT -m conntrack --ctstate INVALID -j DROP'
lines << ''
for cidr in config.private_ranges {
lines << '-A INPUT -i ${iface} -s ${cidr} -j DROP'
}
lines << ''
lines << '-A INPUT -p icmp --icmp-type echo-request -m limit --limit ${config.icmp_rate_limit} --limit-burst ${config.icmp_rate_burst} -j ACCEPT'
lines << '-A INPUT -p icmp --icmp-type echo-reply -j ACCEPT'
lines << '-A INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT'
lines << '-A INPUT -p icmp --icmp-type time-exceeded -j ACCEPT'
lines << ''
for svc in services {
port := config.service_ports[svc] or { continue }
if svc == 'ssh' {
lines << '-A INPUT -p tcp --dport ${port} -m conntrack --ctstate NEW -m limit --limit ${config.ssh_rate_limit} --limit-burst ${config.ssh_rate_burst} -j ACCEPT'
} else if svc == 'dns' {
lines << '-A INPUT -p tcp --dport ${port} -j ACCEPT'
lines << '-A INPUT -p udp --dport ${port} -j ACCEPT'
} else if svc == 'ntp' {
lines << '-A INPUT -p udp --dport ${port} -j ACCEPT'
} else {
lines << '-A INPUT -p tcp --dport ${port} -j ACCEPT'
}
}
lines << ''
lines << '-A INPUT -m limit --limit 5/minute -j LOG --log-prefix "${config.log_prefix_dropped}"'
lines << '-A INPUT -j DROP'
lines << ''
lines << 'COMMIT'
return lines.join('\n')
}
fn generate_nftables_hardened(services []string, iface string) string {
mut lines := []string{}
lines << 'table inet filter {'
lines << ' chain input {'
lines << ' type filter hook input priority 0; policy drop;'
lines << ''
lines << ' iifname "lo" accept'
lines << ''
lines << ' ct state established,related accept'
lines << ' ct state invalid drop'
lines << ''
for cidr in config.private_ranges {
lines << ' iifname "${iface}" ip saddr ${cidr} drop'
}
lines << ''
lines << ' icmp type echo-request limit rate ${config.icmp_rate_limit} burst ${config.icmp_rate_burst} packets accept'
lines << ' icmp type { echo-reply, destination-unreachable, time-exceeded } accept'
lines << ''
for svc in services {
port := config.service_ports[svc] or { continue }
if svc == 'ssh' {
lines << ' tcp dport ${port} ct state new limit rate ${config.ssh_rate_limit} burst ${config.ssh_rate_burst} packets accept'
} else if svc == 'dns' {
lines << ' tcp dport ${port} accept'
lines << ' udp dport ${port} accept'
} else if svc == 'ntp' {
lines << ' udp dport ${port} accept'
} else {
lines << ' tcp dport ${port} accept'
}
}
lines << ''
lines << ' limit rate 5/minute log prefix "${config.log_prefix_dropped}"'
lines << ' drop'
lines << ' }'
lines << ''
lines << ' chain forward {'
lines << ' type filter hook forward priority 0; policy drop;'
lines << ' }'
lines << ''
lines << ' chain output {'
lines << ' type filter hook output priority 0; policy accept;'
lines << ' }'
lines << '}'
return lines.join('\n')
}
pub fn export_ruleset(rs Ruleset, fmt RuleSource) string {
return match fmt {
.iptables { export_as_iptables(rs) }
.nftables { export_as_nftables(rs) }
}
}
fn export_as_iptables(rs Ruleset) string {
mut lines := []string{}
mut tables_seen := map[string]bool{}
for rule in rs.rules {
tbl := rule.table.str()
if tbl !in tables_seen {
if tables_seen.len > 0 {
lines << 'COMMIT'
lines << ''
}
lines << '*${tbl}'
tables_seen[tbl] = true
for chain_name, policy in rs.policies {
lines << ':${chain_name} ${policy.str()} [0:0]'
}
}
lines << rule_to_iptables(rule)
}
if tables_seen.len > 0 {
lines << 'COMMIT'
}
return lines.join('\n')
}
fn export_as_nftables(rs Ruleset) string {
mut lines := []string{}
mut table_chains := map[string]map[string][]int{}
for i, rule in rs.rules {
tbl := rule.table.str()
if tbl !in table_chains {
table_chains[tbl] = map[string][]int{}
}
table_chains[tbl][rule.chain] << i
}
for tbl, chains in table_chains {
lines << 'table inet ${tbl} {'
for chain_name, indices in chains {
policy_str := if p := rs.policies[chain_name] {
p.str().to_lower()
} else {
'accept'
}
chain_lower := chain_name.to_lower()
lines << ' chain ${chain_lower} {'
hook := match chain_name {
'INPUT' { 'input' }
'OUTPUT' { 'output' }
'FORWARD' { 'forward' }
'PREROUTING' { 'prerouting' }
'POSTROUTING' { 'postrouting' }
else { '' }
}
if hook.len > 0 {
lines << ' type filter hook ${hook} priority 0; policy ${policy_str};'
}
for idx in indices {
lines << ' ${rule_to_nftables(rs.rules[idx])}'
}
lines << ' }'
}
lines << '}'
}
return lines.join('\n')
}
fn rule_to_iptables(r Rule) string {
mut parts := []string{}
parts << '-A ${r.chain}'
if r.criteria.protocol != .all {
parts << '-p ${r.criteria.protocol.str()}'
}
if src := r.criteria.source {
if src.negated {
parts << '! -s ${src.address}/${src.cidr}'
} else {
parts << '-s ${src.address}/${src.cidr}'
}
}
if dst := r.criteria.destination {
if dst.negated {
parts << '! -d ${dst.address}/${dst.cidr}'
} else {
parts << '-d ${dst.address}/${dst.cidr}'
}
}
if iface := r.criteria.in_iface {
parts << '-i ${iface}'
}
if oface := r.criteria.out_iface {
parts << '-o ${oface}'
}
if !r.criteria.states.is_empty() {
mut state_strs := []string{}
if r.criteria.states.has(.new_conn) {
state_strs << 'NEW'
}
if r.criteria.states.has(.established) {
state_strs << 'ESTABLISHED'
}
if r.criteria.states.has(.related) {
state_strs << 'RELATED'
}
if r.criteria.states.has(.invalid) {
state_strs << 'INVALID'
}
parts << '-m conntrack --ctstate ${state_strs.join(',')}'
}
if r.criteria.dst_ports.len == 1 {
parts << '--dport ${r.criteria.dst_ports[0].str()}'
} else if r.criteria.dst_ports.len > 1 {
port_strs := r.criteria.dst_ports.map(it.str())
parts << '-m multiport --dports ${port_strs.join(',')}'
}
if rate := r.criteria.limit_rate {
parts << '-m limit --limit ${rate}'
if burst := r.criteria.limit_burst {
parts << '--limit-burst ${burst}'
}
}
parts << '-j ${r.action.str()}'
if r.target_args.len > 0 {
parts << r.target_args
}
return parts.join(' ')
}
fn rule_to_nftables(r Rule) string {
mut parts := []string{}
if iface := r.criteria.in_iface {
parts << 'iifname "${iface}"'
}
if oface := r.criteria.out_iface {
parts << 'oifname "${oface}"'
}
if src := r.criteria.source {
prefix := if src.negated { '!= ' } else { '' }
parts << 'ip saddr ${prefix}${src.address}/${src.cidr}'
}
if dst := r.criteria.destination {
prefix := if dst.negated { '!= ' } else { '' }
parts << 'ip daddr ${prefix}${dst.address}/${dst.cidr}'
}
if !r.criteria.states.is_empty() {
mut state_strs := []string{}
if r.criteria.states.has(.new_conn) {
state_strs << 'new'
}
if r.criteria.states.has(.established) {
state_strs << 'established'
}
if r.criteria.states.has(.related) {
state_strs << 'related'
}
if r.criteria.states.has(.invalid) {
state_strs << 'invalid'
}
parts << 'ct state ${state_strs.join(',')}'
}
if r.criteria.protocol != .all {
proto := r.criteria.protocol.str()
if r.criteria.dst_ports.len == 1 {
parts << '${proto} dport ${r.criteria.dst_ports[0].start}'
} else if r.criteria.dst_ports.len > 1 {
port_strs := r.criteria.dst_ports.map('${it.start}')
parts << '${proto} dport { ${port_strs.join(', ')} }'
} else {
parts << 'ip protocol ${proto}'
}
}
if rate := r.criteria.limit_rate {
parts << 'limit rate ${rate}'
}
action_str := match r.action {
.accept { 'accept' }
.drop { 'drop' }
.reject { 'reject' }
.log { 'log' }
.masquerade { 'masquerade' }
.return_action { 'return' }
else { r.action.str().to_lower() }
}
if r.action == .log && r.target_args.len > 0 {
parts << 'log ${r.target_args}'
} else {
parts << action_str
}
return parts.join(' ')
}

View File

@ -0,0 +1,486 @@
// ©AngelaMos | 2026
// generator_test.v
module generator
import src.models { Action, MatchCriteria, NetworkAddr, PortSpec, Rule, Ruleset }
fn test_generate_iptables_hardened_default_deny() {
output := generate_hardened(['ssh'], 'eth0', .iptables)
assert output.contains(':INPUT DROP')
assert output.contains(':FORWARD DROP')
assert output.contains(':OUTPUT ACCEPT')
}
fn test_generate_iptables_hardened_loopback() {
output := generate_hardened(['ssh'], 'eth0', .iptables)
assert output.contains('-A INPUT -i lo -j ACCEPT')
assert output.contains('-A OUTPUT -o lo -j ACCEPT')
}
fn test_generate_iptables_hardened_conntrack() {
output := generate_hardened(['ssh'], 'eth0', .iptables)
assert output.contains('--ctstate ESTABLISHED,RELATED -j ACCEPT')
assert output.contains('--ctstate INVALID -j DROP')
}
fn test_generate_iptables_hardened_ssh_with_rate_limit() {
output := generate_hardened(['ssh'], 'eth0', .iptables)
assert output.contains('--dport 22')
assert output.contains('--limit 3/minute')
assert output.contains('--limit-burst 5')
}
fn test_generate_iptables_hardened_http_https() {
output := generate_hardened(['http', 'https'], 'eth0', .iptables)
assert output.contains('--dport 80 -j ACCEPT')
assert output.contains('--dport 443 -j ACCEPT')
}
fn test_generate_iptables_hardened_anti_spoofing() {
output := generate_hardened(['ssh'], 'eth0', .iptables)
assert output.contains('-s 10.0.0.0/8 -j DROP')
assert output.contains('-s 172.16.0.0/12 -j DROP')
assert output.contains('-s 192.168.0.0/16 -j DROP')
}
fn test_generate_iptables_hardened_icmp_rate_limit() {
output := generate_hardened(['ssh'], 'eth0', .iptables)
assert output.contains('-p icmp')
assert output.contains('echo-request')
assert output.contains('--limit 1/second')
}
fn test_generate_iptables_hardened_logging() {
output := generate_hardened(['ssh'], 'eth0', .iptables)
assert output.contains('-j LOG')
assert output.contains('DROPPED: ')
}
fn test_generate_iptables_hardened_final_drop() {
output := generate_hardened(['ssh'], 'eth0', .iptables)
lines := output.split('\n')
mut last_rule := ''
for line in lines {
trimmed := line.trim_space()
if trimmed.starts_with('-A INPUT') {
last_rule = trimmed
}
}
assert last_rule == '-A INPUT -j DROP'
}
fn test_generate_iptables_hardened_commit() {
output := generate_hardened(['ssh'], 'eth0', .iptables)
assert output.contains('*filter')
assert output.contains('COMMIT')
}
fn test_generate_iptables_hardened_dns_dual_protocol() {
output := generate_hardened(['dns'], 'eth0', .iptables)
assert output.contains('-p tcp --dport 53 -j ACCEPT')
assert output.contains('-p udp --dport 53 -j ACCEPT')
}
fn test_generate_iptables_hardened_ntp_udp() {
output := generate_hardened(['ntp'], 'eth0', .iptables)
assert output.contains('-p udp --dport 123 -j ACCEPT')
}
fn test_generate_iptables_hardened_custom_iface() {
output := generate_hardened(['ssh'], 'ens192', .iptables)
assert output.contains('-i ens192')
}
fn test_generate_nftables_hardened_structure() {
output := generate_hardened(['ssh', 'http'], 'eth0', .nftables)
assert output.contains('table inet filter {')
assert output.contains('chain input {')
assert output.contains('policy drop;')
assert output.contains('chain forward {')
assert output.contains('chain output {')
}
fn test_generate_nftables_hardened_conntrack() {
output := generate_hardened(['ssh'], 'eth0', .nftables)
assert output.contains('ct state established,related accept')
assert output.contains('ct state invalid drop')
}
fn test_generate_nftables_hardened_ssh_rate_limit() {
output := generate_hardened(['ssh'], 'eth0', .nftables)
assert output.contains('tcp dport 22')
assert output.contains('limit rate 3/minute')
assert output.contains('burst 5')
}
fn test_generate_nftables_hardened_anti_spoofing() {
output := generate_hardened(['ssh'], 'eth0', .nftables)
assert output.contains('ip saddr 10.0.0.0/8 drop')
assert output.contains('ip saddr 172.16.0.0/12 drop')
assert output.contains('ip saddr 192.168.0.0/16 drop')
}
fn test_generate_nftables_hardened_loopback() {
output := generate_hardened(['ssh'], 'eth0', .nftables)
assert output.contains('iifname "lo" accept')
}
fn test_generate_nftables_hardened_dns_dual_protocol() {
output := generate_hardened(['dns'], 'eth0', .nftables)
assert output.contains('tcp dport 53 accept')
assert output.contains('udp dport 53 accept')
}
fn test_rule_to_iptables_tcp_port() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
}
result := rule_to_iptables(r)
assert result.contains('-A INPUT')
assert result.contains('-p tcp')
assert result.contains('--dport 80')
assert result.contains('-j ACCEPT')
}
fn test_rule_to_iptables_with_source() {
r := Rule{
chain: 'INPUT'
action: .drop
criteria: MatchCriteria{
protocol: .tcp
source: NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
}
}
result := rule_to_iptables(r)
assert result.contains('-s 10.0.0.0/8')
assert result.contains('-j DROP')
}
fn test_rule_to_iptables_negated_source() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
source: NetworkAddr{
address: '10.0.0.0'
cidr: 8
negated: true
}
}
}
result := rule_to_iptables(r)
assert result.contains('! -s 10.0.0.0/8')
}
fn test_rule_to_iptables_with_destination() {
r := Rule{
chain: 'FORWARD'
action: .reject
criteria: MatchCriteria{
destination: NetworkAddr{
address: '192.168.1.0'
cidr: 24
}
}
}
result := rule_to_iptables(r)
assert result.contains('-A FORWARD')
assert result.contains('-d 192.168.1.0/24')
assert result.contains('-j REJECT')
}
fn test_rule_to_iptables_multiport() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}, PortSpec{
start: 443
}]
}
}
result := rule_to_iptables(r)
assert result.contains('-m multiport --dports')
}
fn test_rule_to_iptables_interface() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
in_iface: 'lo'
}
}
result := rule_to_iptables(r)
assert result.contains('-i lo')
}
fn test_rule_to_iptables_out_interface() {
r := Rule{
chain: 'OUTPUT'
action: .accept
criteria: MatchCriteria{
out_iface: 'eth0'
}
}
result := rule_to_iptables(r)
assert result.contains('-o eth0')
}
fn test_rule_to_nftables_tcp_port() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 443
}]
}
}
result := rule_to_nftables(r)
assert result.contains('tcp dport 443')
assert result.contains('accept')
}
fn test_rule_to_nftables_with_iface() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
in_iface: 'lo'
}
}
result := rule_to_nftables(r)
assert result.contains('iifname "lo"')
assert result.contains('accept')
}
fn test_rule_to_nftables_with_source() {
r := Rule{
chain: 'INPUT'
action: .drop
criteria: MatchCriteria{
source: NetworkAddr{
address: '10.0.0.0'
cidr: 8
}
}
}
result := rule_to_nftables(r)
assert result.contains('ip saddr 10.0.0.0/8')
assert result.contains('drop')
}
fn test_rule_to_nftables_negated_source() {
r := Rule{
chain: 'INPUT'
action: .drop
criteria: MatchCriteria{
source: NetworkAddr{
address: '10.0.0.0'
cidr: 8
negated: true
}
}
}
result := rule_to_nftables(r)
assert result.contains('!= 10.0.0.0/8')
}
fn test_rule_to_nftables_multiport() {
r := Rule{
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}, PortSpec{
start: 443
}]
}
}
result := rule_to_nftables(r)
assert result.contains('tcp dport {')
assert result.contains('80')
assert result.contains('443')
}
fn test_rule_to_nftables_log_with_prefix() {
r := Rule{
chain: 'INPUT'
action: .log
target_args: 'prefix "DROPPED: "'
}
result := rule_to_nftables(r)
assert result.contains('log prefix "DROPPED: "')
}
fn test_export_ruleset_iptables() {
rs := Ruleset{
rules: [
Rule{
table: .filter
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
source: .iptables
},
]
policies: {
'INPUT': Action.drop
}
source: .iptables
}
output := export_ruleset(rs, .iptables)
assert output.contains('*filter')
assert output.contains(':INPUT DROP')
assert output.contains('-A INPUT')
assert output.contains('COMMIT')
}
fn test_export_ruleset_nftables() {
rs := Ruleset{
rules: [
Rule{
table: .filter
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
source: .iptables
},
]
policies: {
'INPUT': Action.drop
}
source: .iptables
}
output := export_ruleset(rs, .nftables)
assert output.contains('table inet filter')
assert output.contains('chain input')
assert output.contains('tcp dport 80')
}
fn test_export_empty_ruleset() {
rs := Ruleset{
source: .iptables
}
ipt := export_ruleset(rs, .iptables)
nft := export_ruleset(rs, .nftables)
assert ipt.len >= 0
assert nft.len >= 0
}
fn test_export_nftables_multi_table() {
rs := Ruleset{
rules: [
Rule{
table: .filter
chain: 'INPUT'
action: .accept
criteria: MatchCriteria{
protocol: .tcp
dst_ports: [PortSpec{
start: 80
}]
}
source: .iptables
},
Rule{
table: .nat
chain: 'POSTROUTING'
action: .masquerade
criteria: MatchCriteria{
out_iface: 'eth0'
}
source: .iptables
},
]
policies: {
'INPUT': Action.drop
'POSTROUTING': Action.accept
}
source: .iptables
}
output := export_ruleset(rs, .nftables)
assert output.contains('table inet filter {')
assert output.contains('table inet nat {')
filter_pos := output.index('table inet filter') or { -1 }
nat_pos := output.index('table inet nat') or { -1 }
assert filter_pos >= 0 && nat_pos >= 0
assert output.contains('chain input {')
assert output.contains('chain postrouting {')
}
fn test_export_nftables_multi_table_chains_inside_correct_table() {
rs := Ruleset{
rules: [
Rule{
table: .filter
chain: 'INPUT'
action: .accept
source: .iptables
},
Rule{
table: .nat
chain: 'POSTROUTING'
action: .masquerade
source: .iptables
},
]
policies: {
'INPUT': Action.drop
'POSTROUTING': Action.accept
}
source: .iptables
}
output := export_ruleset(rs, .nftables)
lines := output.split('\n')
mut in_filter := false
mut in_nat := false
mut input_in_filter := false
mut postrouting_in_nat := false
for line in lines {
trimmed := line.trim_space()
if trimmed.starts_with('table inet filter') {
in_filter = true
in_nat = false
} else if trimmed.starts_with('table inet nat') {
in_nat = true
in_filter = false
}
if in_filter && trimmed.starts_with('chain input') {
input_in_filter = true
}
if in_nat && trimmed.starts_with('chain postrouting') {
postrouting_in_nat = true
}
}
assert input_in_filter
assert postrouting_in_nat
}

View File

@ -0,0 +1,242 @@
// ©AngelaMos | 2026
// main.v
module main
import os
import flag
import src.config
import src.models
import src.parser
import src.analyzer
import src.generator
import term
import src.display
fn main() {
if os.args.len < 2 {
cmd_help()
exit(config.exit_usage_error)
}
command := os.args[1]
remaining := if os.args.len > 2 { os.args[2..] } else { []string{} }
match command {
'load', 'display' {
cmd_load(remaining)
}
'analyze' {
cmd_analyze(remaining)
}
'optimize' {
cmd_optimize(remaining)
}
'harden' {
cmd_harden(remaining)
}
'export' {
cmd_export(remaining)
}
'diff' {
cmd_diff(remaining)
}
'version', '--version', '-v' {
cmd_version()
}
'help', '--help', '-h' {
cmd_help()
}
else {
eprintln('unknown command: ${command}')
eprintln('Run "${config.app_name} help" for usage')
exit(config.exit_usage_error)
}
}
}
fn cmd_load(args []string) {
if args.len == 0 {
eprintln('usage: ${config.app_name} load <file>')
exit(config.exit_usage_error)
}
rs := load_ruleset(args[0]) or {
eprintln('${err}')
exit(config.exit_parse_error)
}
display.print_banner()
display.print_summary(rs)
display.print_rule_table(rs)
}
fn cmd_analyze(args []string) {
if args.len == 0 {
eprintln('usage: ${config.app_name} analyze <file>')
exit(config.exit_usage_error)
}
rs := load_ruleset(args[0]) or {
eprintln('${err}')
exit(config.exit_parse_error)
}
display.print_banner()
display.print_summary(rs)
println(term.bold(' Conflict Analysis'))
conflicts := analyzer.analyze_conflicts(rs)
display.print_findings(conflicts)
println(term.bold(' Optimization Suggestions'))
optimizations := analyzer.suggest_optimizations(rs)
display.print_findings(optimizations)
}
fn cmd_optimize(args []string) {
if args.len == 0 {
eprintln('usage: ${config.app_name} optimize <file>')
exit(config.exit_usage_error)
}
rs := load_ruleset(args[0]) or {
eprintln('${err}')
exit(config.exit_parse_error)
}
display.print_banner()
println(term.bold(' Optimization Suggestions'))
findings := analyzer.suggest_optimizations(rs)
display.print_findings(findings)
}
fn cmd_harden(args []string) {
mut fp := flag.new_flag_parser(args)
fp.application(config.app_name)
fp.description('Generate a hardened firewall ruleset')
services_str := fp.string('services', `s`, config.default_services.join(','), 'comma-separated list of services to allow')
iface := fp.string('iface', `i`, config.default_iface, 'public-facing network interface')
format_str := fp.string('format', `f`, 'iptables', 'output format (iptables or nftables)')
fp.finalize() or {
eprintln('${err}')
exit(config.exit_usage_error)
}
services := services_str.split(',').map(it.trim_space()).filter(it.len > 0)
out_format := match format_str.to_lower() {
'iptables' {
models.RuleSource.iptables
}
'nftables' {
models.RuleSource.nftables
}
else {
eprintln('invalid format: ${format_str} (use iptables or nftables)')
exit(config.exit_usage_error)
models.RuleSource.iptables
}
}
display.print_banner()
output := generator.generate_hardened(services, iface, out_format)
println(output)
}
fn cmd_export(args []string) {
mut fp := flag.new_flag_parser(args)
fp.application(config.app_name)
fp.description('Export ruleset in a different format')
format_str := fp.string('format', `f`, 'nftables', 'output format (iptables or nftables)')
remaining := fp.finalize() or {
eprintln('${err}')
exit(config.exit_usage_error)
[]string{}
}
if remaining.len == 0 {
eprintln('usage: ${config.app_name} export <file> --format <iptables|nftables>')
exit(config.exit_usage_error)
}
rs := load_ruleset(remaining[0]) or {
eprintln('${err}')
exit(config.exit_parse_error)
}
out_format := match format_str.to_lower() {
'nftables' {
models.RuleSource.nftables
}
'iptables' {
models.RuleSource.iptables
}
else {
eprintln('invalid format: ${format_str}')
exit(config.exit_usage_error)
models.RuleSource.iptables
}
}
output := generator.export_ruleset(rs, out_format)
println(output)
}
fn cmd_diff(args []string) {
if args.len < 2 {
eprintln('usage: ${config.app_name} diff <file1> <file2>')
exit(config.exit_usage_error)
}
left := load_ruleset(args[0]) or {
eprintln('${err}')
exit(config.exit_parse_error)
}
right := load_ruleset(args[1]) or {
eprintln('${err}')
exit(config.exit_parse_error)
}
display.print_banner()
display.print_diff(left, right)
}
fn cmd_version() {
println('${config.app_name} v${config.version}')
}
fn cmd_help() {
println('${config.app_name} v${config.version} - Firewall Rule Engine')
println('')
println('USAGE:')
println(' ${config.app_name} <command> [options]')
println('')
println('COMMANDS:')
println(' load <file> Load and display a ruleset')
println(' analyze <file> Run conflict detection and optimization analysis')
println(' optimize <file> Show optimization suggestions')
println(' harden Generate a hardened ruleset')
println(' export <file> Convert ruleset between iptables/nftables formats')
println(' diff <f1> <f2> Compare two rulesets')
println(' version Show version')
println(' help Show this help')
println('')
println('HARDEN OPTIONS:')
println(' -s, --services Services to allow (default: ssh,http,https)')
println(' -i, --iface Public interface (default: eth0)')
println(' -f, --format Output format: iptables or nftables (default: iptables)')
println('')
println('EXPORT OPTIONS:')
println(' -f, --format Target format: iptables or nftables (default: nftables)')
println('')
println('EXAMPLES:')
println(' ${config.app_name} load rules.txt')
println(' ${config.app_name} analyze /etc/iptables.rules')
println(' ${config.app_name} harden -s ssh,http,https -f nftables')
println(' ${config.app_name} export rules.txt -f nftables')
println(' ${config.app_name} diff old.rules new.rules')
}
fn load_ruleset(path string) !models.Ruleset {
if !os.exists(path) {
return error('file not found: ${path}')
}
content := os.read_file(path) or { return error('cannot read file: ${path}') }
fmt := parser.detect_format(content)!
return match fmt {
.iptables { parser.parse_iptables(content)! }
.nftables { parser.parse_nftables(content)! }
}
}

View File

@ -0,0 +1,291 @@
// ©AngelaMos | 2026
// models.v
module models
pub enum Protocol as u8 {
tcp
udp
icmp
icmpv6
all
sctp
gre
}
pub fn (p Protocol) str() string {
return match p {
.tcp { 'tcp' }
.udp { 'udp' }
.icmp { 'icmp' }
.icmpv6 { 'icmpv6' }
.all { 'all' }
.sctp { 'sctp' }
.gre { 'gre' }
}
}
pub enum Action as u8 {
accept
drop
reject
log
masquerade
snat
dnat
return_action
jump
queue
}
pub fn (a Action) str() string {
return match a {
.accept { 'ACCEPT' }
.drop { 'DROP' }
.reject { 'REJECT' }
.log { 'LOG' }
.masquerade { 'MASQUERADE' }
.snat { 'SNAT' }
.dnat { 'DNAT' }
.return_action { 'RETURN' }
.jump { 'JUMP' }
.queue { 'QUEUE' }
}
}
pub enum Table as u8 {
filter
nat
mangle
raw
security
}
pub fn (t Table) str() string {
return match t {
.filter { 'filter' }
.nat { 'nat' }
.mangle { 'mangle' }
.raw { 'raw' }
.security { 'security' }
}
}
pub enum ChainType as u8 {
input
output
forward
prerouting
postrouting
custom
}
pub fn (c ChainType) str() string {
return match c {
.input { 'INPUT' }
.output { 'OUTPUT' }
.forward { 'FORWARD' }
.prerouting { 'PREROUTING' }
.postrouting { 'POSTROUTING' }
.custom { 'CUSTOM' }
}
}
pub enum RuleSource as u8 {
iptables
nftables
}
pub fn (r RuleSource) str() string {
return match r {
.iptables { 'iptables' }
.nftables { 'nftables' }
}
}
pub enum Severity as u8 {
info
warning
critical
}
pub fn (s Severity) str() string {
return match s {
.info { 'INFO' }
.warning { 'WARNING' }
.critical { 'CRITICAL' }
}
}
@[flag]
pub enum ConnState {
new_conn
established
related
invalid
untracked
}
pub struct NetworkAddr {
pub:
address string
cidr int = 32
negated bool
}
pub fn (n NetworkAddr) str() string {
mut s := ''
if n.negated {
s += '!'
}
s += n.address
if n.cidr != 32 {
s += '/${n.cidr}'
}
return s
}
pub fn ip_to_u32(ip string) !u32 {
parts := ip.split('.')
if parts.len != 4 {
return error('invalid IPv4 address: ${ip}')
}
mut result := u32(0)
for part in parts {
trimmed := part.trim_space()
if trimmed.len == 0 {
return error('invalid octet in address: ${ip}')
}
for ch in trimmed.bytes() {
if ch < `0` || ch > `9` {
return error('invalid octet in address: ${ip}')
}
}
val := trimmed.int()
if val < 0 || val > 255 {
return error('invalid octet in address: ${ip}')
}
result = (result << 8) | u32(val)
}
return result
}
pub fn cidr_contains(outer NetworkAddr, inner NetworkAddr) bool {
outer_ip := ip_to_u32(outer.address) or { return false }
inner_ip := ip_to_u32(inner.address) or { return false }
if outer.cidr > inner.cidr {
return false
}
if outer.cidr == 0 {
return true
}
shift := u32(32 - outer.cidr)
return (outer_ip >> shift) == (inner_ip >> shift)
}
pub struct PortSpec {
pub:
start int
end int = -1
negated bool
}
pub fn (p PortSpec) str() string {
mut s := ''
if p.negated {
s += '!'
}
s += '${p.start}'
if p.end > 0 && p.end != p.start {
s += ':${p.end}'
}
return s
}
pub fn (p PortSpec) effective_end() int {
if p.end < 0 {
return p.start
}
return p.end
}
pub fn port_range_contains(outer PortSpec, inner PortSpec) bool {
return outer.start <= inner.start && outer.effective_end() >= inner.effective_end()
}
pub struct MatchCriteria {
pub:
protocol Protocol = .all
source ?NetworkAddr
destination ?NetworkAddr
src_ports []PortSpec
dst_ports []PortSpec
in_iface ?string
out_iface ?string
states ConnState
icmp_type ?string
limit_rate ?string
limit_burst ?int
comment ?string
}
pub struct Rule {
pub:
table Table = .filter
chain string
chain_type ChainType
action Action
criteria MatchCriteria
target_args string
line_number int
raw_text string
source RuleSource
}
pub fn (r Rule) str() string {
mut parts := []string{}
parts << r.chain
parts << r.criteria.protocol.str()
if src := r.criteria.source {
parts << src.str()
} else {
parts << '*'
}
if dst := r.criteria.destination {
parts << dst.str()
} else {
parts << '*'
}
if r.criteria.dst_ports.len > 0 {
port_strs := r.criteria.dst_ports.map(it.str())
parts << port_strs.join(',')
} else {
parts << '*'
}
parts << r.action.str()
return parts.join('\t')
}
pub struct Finding {
pub:
severity Severity
title string
description string
rule_indices []int
suggestion string
}
pub struct Ruleset {
pub mut:
rules []Rule
policies map[string]Action
source RuleSource
}
pub fn (rs Ruleset) rules_by_chain() map[string][]int {
mut result := map[string][]int{}
for i, rule in rs.rules {
result[rule.chain] << i
}
return result
}

View File

@ -0,0 +1,173 @@
// ©AngelaMos | 2026
// common.v
module parser
import src.models { Action, ChainType, ConnState, NetworkAddr, PortSpec, Protocol, RuleSource, Table }
pub fn parse_network_addr(s string) !NetworkAddr {
mut input := s.trim_space()
mut negated := false
if input.starts_with('!') {
negated = true
input = input[1..].trim_space()
}
if input.contains('/') {
parts := input.split('/')
if parts.len != 2 {
return error('invalid CIDR notation: ${s}')
}
cidr := parts[1].int()
if cidr < 0 || cidr > 128 {
return error('invalid CIDR prefix length: ${parts[1]}')
}
return NetworkAddr{
address: parts[0]
cidr: cidr
negated: negated
}
}
return NetworkAddr{
address: input
cidr: 32
negated: negated
}
}
pub fn parse_port_spec(s string) !PortSpec {
mut input := s.trim_space()
mut negated := false
if input.starts_with('!') {
negated = true
input = input[1..].trim_space()
}
if input.contains(':') {
parts := input.split(':')
if parts.len != 2 {
return error('invalid port range: ${s}')
}
start := parts[0].int()
end := parts[1].int()
if start < 0 || start > 65535 || end < 0 || end > 65535 {
return error('port out of range: ${s}')
}
return PortSpec{
start: start
end: end
negated: negated
}
}
port := input.int()
if port < 0 || port > 65535 {
return error('port out of range: ${s}')
}
return PortSpec{
start: port
end: -1
negated: negated
}
}
pub fn parse_port_list(s string) ![]PortSpec {
mut result := []PortSpec{}
parts := s.split(',')
for part in parts {
trimmed := part.trim_space()
if trimmed.len == 0 {
continue
}
result << parse_port_spec(trimmed)!
}
return result
}
pub fn parse_protocol(s string) !Protocol {
return match s.to_lower().trim_space() {
'tcp', '6' { .tcp }
'udp', '17' { .udp }
'icmp', '1' { .icmp }
'icmpv6', 'ipv6-icmp', '58' { .icmpv6 }
'all', '0' { .all }
'sctp', '132' { .sctp }
'gre', '47' { .gre }
else { error('unknown protocol: ${s}') }
}
}
pub fn parse_action(s string) !Action {
return match s.to_upper().trim_space() {
'ACCEPT' { .accept }
'DROP' { .drop }
'REJECT' { .reject }
'LOG' { .log }
'MASQUERADE' { .masquerade }
'SNAT' { .snat }
'DNAT' { .dnat }
'RETURN' { .return_action }
'JUMP' { .jump }
'QUEUE' { .queue }
else { error('unknown action: ${s}') }
}
}
pub fn parse_table(s string) !Table {
return match s.to_lower().trim_space() {
'filter' { .filter }
'nat' { .nat }
'mangle' { .mangle }
'raw' { .raw }
'security' { .security }
else { error('unknown table: ${s}') }
}
}
pub fn parse_chain_type(s string) ChainType {
return match s.to_upper().trim_space() {
'INPUT' { .input }
'OUTPUT' { .output }
'FORWARD' { .forward }
'PREROUTING' { .prerouting }
'POSTROUTING' { .postrouting }
else { .custom }
}
}
pub fn parse_conn_states(s string) ConnState {
mut result := ConnState.zero()
parts := s.to_upper().split(',')
for part in parts {
match part.trim_space() {
'NEW' { result.set(.new_conn) }
'ESTABLISHED' { result.set(.established) }
'RELATED' { result.set(.related) }
'INVALID' { result.set(.invalid) }
'UNTRACKED' { result.set(.untracked) }
else {}
}
}
return result
}
pub fn detect_format(content string) !RuleSource {
lines := content.split('\n')
for line in lines {
trimmed := line.trim_space()
if trimmed.len == 0 || trimmed.starts_with('#') {
continue
}
if trimmed.starts_with('*') {
return .iptables
}
if trimmed.starts_with('table') {
return .nftables
}
if trimmed.starts_with(':') {
return .iptables
}
if trimmed.starts_with('-A') || trimmed.starts_with('-I') {
return .iptables
}
break
}
return error('unable to detect ruleset format')
}

View File

@ -0,0 +1,303 @@
// ©AngelaMos | 2026
// iptables.v
module parser
import os
import src.models { Action, MatchCriteria, NetworkAddr, Rule, Ruleset, Table }
pub fn parse_iptables_file(path string) !Ruleset {
content := os.read_file(path) or { return error('cannot read file: ${path}') }
return parse_iptables(content)
}
pub fn parse_iptables(content string) !Ruleset {
mut ruleset := Ruleset{
source: .iptables
}
mut current_table := Table.filter
lines := content.split('\n')
for i, line in lines {
trimmed := line.trim_space()
if trimmed.len == 0 || trimmed.starts_with('#') {
continue
}
if trimmed == 'COMMIT' {
continue
}
if trimmed.starts_with('*') {
current_table = parse_table(trimmed[1..])!
continue
}
if trimmed.starts_with(':') {
chain_name, policy := parse_chain_policy(trimmed)!
ruleset.policies[chain_name] = policy
continue
}
if trimmed.starts_with('-A') || trimmed.starts_with('-I') {
rule := parse_iptables_rule(trimmed, current_table, i + 1)!
ruleset.rules << rule
}
}
return ruleset
}
fn parse_table_header(line string) !Table {
if !line.starts_with('*') {
return error('expected table header starting with *')
}
return parse_table(line[1..])
}
fn parse_chain_policy(line string) !(string, Action) {
if !line.starts_with(':') {
return error('expected chain policy starting with :')
}
content := line[1..]
parts := content.split(' ')
if parts.len < 2 {
return error('invalid chain policy: ${line}')
}
chain_name := parts[0]
action := parse_action(parts[1])!
return chain_name, action
}
fn parse_iptables_rule(line string, current_table Table, line_num int) !Rule {
tokens := tokenize_iptables(line)
mut i := 0
mut chain := ''
mut protocol := models.Protocol.all
mut source := ?NetworkAddr(none)
mut destination := ?NetworkAddr(none)
mut src_ports := []models.PortSpec{}
mut dst_ports := []models.PortSpec{}
mut in_iface := ?string(none)
mut out_iface := ?string(none)
mut states := models.ConnState.zero()
mut icmp_type := ?string(none)
mut limit_rate := ?string(none)
mut limit_burst := ?int(none)
mut comment := ?string(none)
mut action := Action.accept
mut target_args := ''
mut next_negated := false
for i < tokens.len {
tok := tokens[i]
match tok {
'!' {
next_negated = true
i++
continue
}
'-A', '-I' {
i++
if i < tokens.len {
chain = tokens[i]
}
}
'-p', '--protocol' {
i++
if i < tokens.len {
protocol = parse_protocol(tokens[i])!
}
}
'-s', '--source' {
i++
if i < tokens.len {
mut addr := parse_network_addr(tokens[i])!
if next_negated {
addr = NetworkAddr{
address: addr.address
cidr: addr.cidr
negated: true
}
next_negated = false
}
source = addr
}
}
'-d', '--destination' {
i++
if i < tokens.len {
mut addr := parse_network_addr(tokens[i])!
if next_negated {
addr = NetworkAddr{
address: addr.address
cidr: addr.cidr
negated: true
}
next_negated = false
}
destination = addr
}
}
'--sport', '--source-port' {
i++
if i < tokens.len {
mut ps := parse_port_spec(tokens[i])!
if next_negated {
ps = models.PortSpec{
start: ps.start
end: ps.end
negated: true
}
next_negated = false
}
src_ports << ps
}
}
'--dport', '--destination-port' {
i++
if i < tokens.len {
mut ps := parse_port_spec(tokens[i])!
if next_negated {
ps = models.PortSpec{
start: ps.start
end: ps.end
negated: true
}
next_negated = false
}
dst_ports << ps
}
}
'--dports' {
i++
if i < tokens.len {
dst_ports = parse_port_list(tokens[i])!
}
}
'--sports' {
i++
if i < tokens.len {
src_ports = parse_port_list(tokens[i])!
}
}
'-i', '--in-interface' {
i++
if i < tokens.len {
in_iface = tokens[i]
}
}
'-o', '--out-interface' {
i++
if i < tokens.len {
out_iface = tokens[i]
}
}
'--state', '--ctstate' {
i++
if i < tokens.len {
states = parse_conn_states(tokens[i])
}
}
'--icmp-type' {
i++
if i < tokens.len {
icmp_type = tokens[i]
}
}
'--limit' {
i++
if i < tokens.len {
limit_rate = tokens[i]
}
}
'--limit-burst' {
i++
if i < tokens.len {
limit_burst = tokens[i].int()
}
}
'--comment' {
i++
if i < tokens.len {
comment = tokens[i]
}
}
'-j', '--jump', '-g', '--goto' {
i++
if i < tokens.len {
action = parse_action(tokens[i]) or { Action.jump }
if i + 1 < tokens.len && tokens[i + 1].starts_with('--') {
mut args := []string{}
for i + 1 < tokens.len {
i++
args << tokens[i]
}
target_args = args.join(' ')
}
}
}
'-m', '--match' {
i++
}
else {
next_negated = false
}
}
i++
}
return Rule{
table: current_table
chain: chain
chain_type: parse_chain_type(chain)
action: action
criteria: MatchCriteria{
protocol: protocol
source: source
destination: destination
src_ports: src_ports
dst_ports: dst_ports
in_iface: in_iface
out_iface: out_iface
states: states
icmp_type: icmp_type
limit_rate: limit_rate
limit_burst: limit_burst
comment: comment
}
target_args: target_args
line_number: line_num
raw_text: line
source: .iptables
}
}
fn tokenize_iptables(line string) []string {
mut tokens := []string{}
mut current := []u8{}
mut in_quote := false
mut quote_char := u8(0)
for ch in line.bytes() {
if in_quote {
if ch == quote_char {
in_quote = false
if current.len > 0 {
tokens << current.bytestr()
current.clear()
}
} else {
current << ch
}
} else if ch == `"` || ch == `'` {
in_quote = true
quote_char = ch
} else if ch == ` ` || ch == `\t` {
if current.len > 0 {
tokens << current.bytestr()
current.clear()
}
} else {
current << ch
}
}
if current.len > 0 {
tokens << current.bytestr()
}
return tokens
}

View File

@ -0,0 +1,358 @@
// ©AngelaMos | 2026
// nftables.v
module parser
import os
import src.models { Action, MatchCriteria, NetworkAddr, Rule, Ruleset, Table }
pub fn parse_nftables_file(path string) !Ruleset {
content := os.read_file(path) or { return error('cannot read file: ${path}') }
return parse_nftables(content)
}
pub fn parse_nftables(content string) !Ruleset {
mut ruleset := Ruleset{
source: .nftables
}
lines := content.split('\n')
mut i := 0
for i < lines.len {
trimmed := lines[i].trim_space()
if trimmed.len == 0 || trimmed.starts_with('#') {
i++
continue
}
if trimmed.starts_with('table') {
tbl, new_i := parse_nft_table(mut ruleset, lines, i)!
_ = tbl
i = new_i
continue
}
i++
}
return ruleset
}
fn parse_nft_table(mut ruleset Ruleset, lines []string, start int) !(Table, int) {
header := lines[start].trim_space()
parts := header.replace('{', '').trim_space().split(' ')
mut table_name := ''
for part in parts {
trimmed := part.trim_space()
if trimmed.len == 0 || trimmed == 'table' || trimmed == 'inet' || trimmed == 'ip'
|| trimmed == 'ip6' || trimmed == 'arp' || trimmed == 'bridge' || trimmed == 'netdev' {
continue
}
table_name = trimmed
break
}
tbl := parse_table(table_name) or { Table.filter }
mut i := start + 1
for i < lines.len {
trimmed := lines[i].trim_space()
if trimmed == '}' {
return tbl, i + 1
}
if trimmed.starts_with('chain') {
chain_name, new_i := parse_nft_chain(mut ruleset, lines, i, tbl)!
_ = chain_name
i = new_i
continue
}
i++
}
return tbl, i
}
fn parse_nft_chain(mut ruleset Ruleset, lines []string, start int, tbl Table) !(string, int) {
header := lines[start].trim_space()
chain_name := header.replace('chain', '').replace('{', '').trim_space()
chain_type := parse_chain_type(chain_name)
mut i := start + 1
mut line_in_chain := 0
for i < lines.len {
trimmed := lines[i].trim_space()
if trimmed == '}' {
return chain_name, i + 1
}
if trimmed.starts_with('type') {
policy := extract_nft_policy(trimmed)
if p := policy {
ruleset.policies[chain_name.to_upper()] = p
}
i++
continue
}
if trimmed.len > 0 && !trimmed.starts_with('#') {
rule := parse_nft_rule(trimmed, tbl, chain_name.to_upper(), chain_type, i + 1) or {
i++
continue
}
ruleset.rules << rule
line_in_chain++
}
i++
}
return chain_name, i
}
fn extract_nft_policy(line string) ?Action {
if !line.contains('policy') {
return none
}
parts := line.split(';')
for part in parts {
trimmed := part.trim_space()
if trimmed.starts_with('policy') {
policy_str := trimmed.replace('policy', '').trim_space().trim_right(';')
return parse_action(policy_str) or { return none }
}
}
return none
}
fn parse_nft_rule(line string, tbl Table, chain string, chain_type models.ChainType, line_num int) !Rule {
tokens := line.split(' ').map(it.trim_space()).filter(it.len > 0)
mut protocol := models.Protocol.all
mut source := ?NetworkAddr(none)
mut destination := ?NetworkAddr(none)
mut dst_ports := []models.PortSpec{}
mut src_ports := []models.PortSpec{}
mut in_iface := ?string(none)
mut out_iface := ?string(none)
mut states := models.ConnState.zero()
mut limit_rate := ?string(none)
mut comment := ?string(none)
mut action := ?Action(none)
mut target_args := ''
mut i := 0
for i < tokens.len {
tok := tokens[i]
match tok {
'tcp' {
protocol = .tcp
i++
if i < tokens.len {
i = parse_nft_port_match(tokens, i, mut dst_ports, mut src_ports)
continue
}
}
'udp' {
protocol = .udp
i++
if i < tokens.len {
i = parse_nft_port_match(tokens, i, mut dst_ports, mut src_ports)
continue
}
}
'ip', 'ip6' {
i++
if i < tokens.len {
match tokens[i] {
'saddr' {
i++
if i < tokens.len {
source = parse_network_addr(tokens[i]) or { continue }
}
}
'daddr' {
i++
if i < tokens.len {
destination = parse_network_addr(tokens[i]) or { continue }
}
}
'protocol' {
i++
if i < tokens.len {
protocol = parse_protocol(tokens[i]) or { models.Protocol.all }
}
}
else {}
}
}
}
'ct' {
i++
if i < tokens.len && tokens[i] == 'state' {
i++
if i < tokens.len {
states = parse_conn_states(tokens[i])
}
}
}
'iifname', 'iif' {
i++
if i < tokens.len {
in_iface = tokens[i].replace('"', '')
}
}
'oifname', 'oif' {
i++
if i < tokens.len {
out_iface = tokens[i].replace('"', '')
}
}
'limit' {
i++
if i < tokens.len && tokens[i] == 'rate' {
i++
mut rate_parts := []string{}
for i < tokens.len {
t := tokens[i]
if t == 'accept' || t == 'drop' || t == 'reject' || t == 'log'
|| t == 'counter' {
break
}
rate_parts << t
i++
}
limit_rate = rate_parts.join(' ')
continue
}
}
'log' {
if action == none {
action = .log
}
i++
if i < tokens.len && tokens[i] == 'prefix' {
i++
if i < tokens.len {
target_args = 'prefix ${tokens[i]}'
}
}
continue
}
'counter' {
i++
continue
}
'comment' {
i++
if i < tokens.len {
comment = tokens[i].replace('"', '')
}
}
'accept' {
action = .accept
}
'drop' {
action = .drop
}
'reject' {
action = .reject
}
'masquerade' {
action = .masquerade
}
'queue' {
action = .queue
}
'return' {
action = .return_action
}
'dnat' {
action = .dnat
i++
if i < tokens.len && tokens[i] == 'to' {
i++
if i < tokens.len {
target_args = 'to ${tokens[i]}'
}
}
continue
}
'snat' {
action = .snat
i++
if i < tokens.len && tokens[i] == 'to' {
i++
if i < tokens.len {
target_args = 'to ${tokens[i]}'
}
}
continue
}
else {}
}
i++
}
final_action := action or { return error('no action found in rule: ${line}') }
return Rule{
table: tbl
chain: chain
chain_type: chain_type
action: final_action
criteria: MatchCriteria{
protocol: protocol
source: source
destination: destination
src_ports: src_ports
dst_ports: dst_ports
in_iface: in_iface
out_iface: out_iface
states: states
limit_rate: limit_rate
comment: comment
}
target_args: target_args
line_number: line_num
raw_text: line
source: .nftables
}
}
fn parse_nft_port_match(tokens []string, start int, mut dst_ports []models.PortSpec, mut src_ports []models.PortSpec) int {
mut i := start
if i >= tokens.len {
return i
}
is_dport := tokens[i] == 'dport'
is_sport := tokens[i] == 'sport'
if !is_dport && !is_sport {
return i
}
i++
if i >= tokens.len {
return i
}
if tokens[i] == '{' {
i++
mut port_str := []string{}
for i < tokens.len && tokens[i] != '}' {
cleaned := tokens[i].replace(',', '').trim_space()
if cleaned.len > 0 {
port_str << cleaned
}
i++
}
if i < tokens.len && tokens[i] == '}' {
i++
}
for ps in port_str {
if p := parse_port_spec(ps) {
if is_dport {
dst_ports << p
} else {
src_ports << p
}
}
}
} else {
if p := parse_port_spec(tokens[i]) {
if is_dport {
dst_ports << p
} else {
src_ports << p
}
}
i++
}
return i
}

View File

@ -0,0 +1,504 @@
// ©AngelaMos | 2026
// parser_test.v
module parser
import os
import src.models
fn test_parse_network_addr_plain() {
addr := parse_network_addr('192.168.1.1')!
assert addr.address == '192.168.1.1'
assert addr.cidr == 32
assert addr.negated == false
}
fn test_parse_network_addr_cidr() {
addr := parse_network_addr('10.0.0.0/8')!
assert addr.address == '10.0.0.0'
assert addr.cidr == 8
assert addr.negated == false
}
fn test_parse_network_addr_cidr_24() {
addr := parse_network_addr('192.168.1.0/24')!
assert addr.address == '192.168.1.0'
assert addr.cidr == 24
}
fn test_parse_network_addr_negated() {
addr := parse_network_addr('!172.16.0.0/12')!
assert addr.address == '172.16.0.0'
assert addr.cidr == 12
assert addr.negated == true
}
fn test_parse_port_spec_single() {
ps := parse_port_spec('80')!
assert ps.start == 80
assert ps.end == -1
assert ps.negated == false
}
fn test_parse_port_spec_range() {
ps := parse_port_spec('1024:65535')!
assert ps.start == 1024
assert ps.end == 65535
assert ps.negated == false
}
fn test_parse_port_spec_negated() {
ps := parse_port_spec('!22')!
assert ps.start == 22
assert ps.negated == true
}
fn test_parse_port_list() {
ports := parse_port_list('80,443,8080')!
assert ports.len == 3
assert ports[0].start == 80
assert ports[1].start == 443
assert ports[2].start == 8080
}
fn test_parse_port_list_with_spaces() {
ports := parse_port_list('22, 80, 443')!
assert ports.len == 3
assert ports[0].start == 22
assert ports[1].start == 80
assert ports[2].start == 443
}
fn test_parse_protocol_tcp() {
p := parse_protocol('tcp')!
assert p == .tcp
}
fn test_parse_protocol_udp() {
p := parse_protocol('udp')!
assert p == .udp
}
fn test_parse_protocol_icmp() {
p := parse_protocol('icmp')!
assert p == .icmp
}
fn test_parse_protocol_number_tcp() {
p := parse_protocol('6')!
assert p == .tcp
}
fn test_parse_protocol_number_udp() {
p := parse_protocol('17')!
assert p == .udp
}
fn test_parse_protocol_case_insensitive() {
p := parse_protocol('TCP')!
assert p == .tcp
}
fn test_parse_action_accept() {
a := parse_action('ACCEPT')!
assert a == .accept
}
fn test_parse_action_drop() {
a := parse_action('DROP')!
assert a == .drop
}
fn test_parse_action_reject() {
a := parse_action('REJECT')!
assert a == .reject
}
fn test_parse_action_log() {
a := parse_action('LOG')!
assert a == .log
}
fn test_parse_action_masquerade() {
a := parse_action('MASQUERADE')!
assert a == .masquerade
}
fn test_parse_table_filter() {
t := parse_table('filter')!
assert t == .filter
}
fn test_parse_table_nat() {
t := parse_table('nat')!
assert t == .nat
}
fn test_parse_table_mangle() {
t := parse_table('mangle')!
assert t == .mangle
}
fn test_parse_chain_type_input() {
ct := parse_chain_type('INPUT')
assert ct == .input
}
fn test_parse_chain_type_output() {
ct := parse_chain_type('OUTPUT')
assert ct == .output
}
fn test_parse_chain_type_forward() {
ct := parse_chain_type('FORWARD')
assert ct == .forward
}
fn test_parse_chain_type_custom() {
ct := parse_chain_type('MYCHAIN')
assert ct == .custom
}
fn test_parse_conn_states_single() {
states := parse_conn_states('ESTABLISHED')
assert states.has(.established)
assert !states.has(.related)
assert !states.has(.new_conn)
}
fn test_parse_conn_states_multiple() {
states := parse_conn_states('ESTABLISHED,RELATED')
assert states.has(.established)
assert states.has(.related)
assert !states.has(.new_conn)
}
fn test_parse_conn_states_all_four() {
states := parse_conn_states('NEW,ESTABLISHED,RELATED,INVALID')
assert states.has(.new_conn)
assert states.has(.established)
assert states.has(.related)
assert states.has(.invalid)
}
fn test_parse_conn_states_case_insensitive() {
states := parse_conn_states('established,related')
assert states.has(.established)
assert states.has(.related)
}
fn test_detect_format_iptables_table_header() {
fmt := detect_format('*filter\n:INPUT DROP [0:0]')!
assert fmt == .iptables
}
fn test_detect_format_iptables_chain_policy() {
fmt := detect_format(':INPUT DROP [0:0]\n-A INPUT -j ACCEPT')!
assert fmt == .iptables
}
fn test_detect_format_iptables_rule_line() {
fmt := detect_format('-A INPUT -j ACCEPT')!
assert fmt == .iptables
}
fn test_detect_format_nftables() {
fmt := detect_format('table inet filter {\n chain input {\n')!
assert fmt == .nftables
}
fn test_detect_format_skips_comments() {
fmt := detect_format('# this is a comment\n*filter\n')!
assert fmt == .iptables
}
fn test_parse_iptables_basic_rule_count() {
content := os.read_file(@VMODROOT + '/testdata/iptables_basic.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_iptables(content)!
assert rs.rules.len == 9
assert rs.source == .iptables
}
fn test_parse_iptables_basic_policies() {
content := os.read_file(@VMODROOT + '/testdata/iptables_basic.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_iptables(content)!
assert rs.policies['INPUT'] == models.Action.drop
assert rs.policies['FORWARD'] == models.Action.drop
assert rs.policies['OUTPUT'] == models.Action.accept
}
fn test_parse_iptables_basic_first_rule() {
content := os.read_file(@VMODROOT + '/testdata/iptables_basic.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_iptables(content)!
assert rs.rules[0].chain == 'INPUT'
assert rs.rules[0].action == .accept
assert rs.rules[0].table == .filter
}
fn test_parse_iptables_basic_ssh_rule() {
content := os.read_file(@VMODROOT + '/testdata/iptables_basic.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_iptables(content)!
assert rs.rules[3].criteria.protocol == .tcp
assert rs.rules[3].criteria.dst_ports.len == 1
assert rs.rules[3].criteria.dst_ports[0].start == 22
assert rs.rules[3].action == .accept
}
fn test_parse_iptables_basic_conntrack() {
content := os.read_file(@VMODROOT + '/testdata/iptables_basic.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_iptables(content)!
assert rs.rules[1].criteria.states.has(.established)
assert rs.rules[1].criteria.states.has(.related)
assert rs.rules[2].criteria.states.has(.invalid)
}
fn test_parse_iptables_conflicts_rule_count() {
content := os.read_file(@VMODROOT + '/testdata/iptables_conflicts.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_iptables(content)!
assert rs.rules.len == 11
}
fn test_parse_iptables_conflicts_source_addr() {
content := os.read_file(@VMODROOT + '/testdata/iptables_conflicts.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_iptables(content)!
src := rs.rules[3].criteria.source or { panic('expected source address') }
assert src.address == '10.0.0.0'
assert src.cidr == 8
}
fn test_parse_nftables_basic_rule_count() {
content := os.read_file(@VMODROOT + '/testdata/nftables_basic.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_nftables(content)!
assert rs.rules.len == 8
assert rs.source == .nftables
}
fn test_parse_nftables_basic_policies() {
content := os.read_file(@VMODROOT + '/testdata/nftables_basic.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_nftables(content)!
assert rs.policies['INPUT'] == models.Action.drop
assert rs.policies['FORWARD'] == models.Action.drop
assert rs.policies['OUTPUT'] == models.Action.accept
}
fn test_parse_nftables_basic_first_rule() {
content := os.read_file(@VMODROOT + '/testdata/nftables_basic.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_nftables(content)!
assert rs.rules[0].chain == 'INPUT'
assert rs.rules[0].action == .accept
}
fn test_parse_nftables_basic_conntrack() {
content := os.read_file(@VMODROOT + '/testdata/nftables_basic.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_nftables(content)!
assert rs.rules[1].criteria.states.has(.established)
assert rs.rules[1].criteria.states.has(.related)
assert rs.rules[2].criteria.states.has(.invalid)
}
fn test_parse_nftables_basic_tcp_port() {
content := os.read_file(@VMODROOT + '/testdata/nftables_basic.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_nftables(content)!
assert rs.rules[3].criteria.protocol == .tcp
assert rs.rules[3].criteria.dst_ports.len == 1
assert rs.rules[3].criteria.dst_ports[0].start == 22
}
fn test_tokenize_iptables_basic() {
tokens := tokenize_iptables('-A INPUT -p tcp --dport 22 -j ACCEPT')
assert tokens.len == 8
assert tokens[0] == '-A'
assert tokens[1] == 'INPUT'
assert tokens[2] == '-p'
assert tokens[3] == 'tcp'
assert tokens[4] == '--dport'
assert tokens[5] == '22'
assert tokens[6] == '-j'
assert tokens[7] == 'ACCEPT'
}
fn test_tokenize_iptables_quoted_string() {
tokens := tokenize_iptables('-j LOG --log-prefix "DROPPED: "')
assert tokens.len == 4
assert tokens[0] == '-j'
assert tokens[1] == 'LOG'
assert tokens[2] == '--log-prefix'
assert tokens[3] == 'DROPPED: '
}
fn test_tokenize_iptables_single_quotes() {
tokens := tokenize_iptables("--comment 'my rule'")
assert tokens.len == 2
assert tokens[0] == '--comment'
assert tokens[1] == 'my rule'
}
fn test_parse_iptables_complex_multiport() {
content := os.read_file(@VMODROOT + '/testdata/iptables_complex.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_iptables(content)!
mut found_multiport := false
for rule in rs.rules {
if rule.criteria.dst_ports.len == 2 {
if rule.criteria.dst_ports[0].start == 80 && rule.criteria.dst_ports[1].start == 443 {
found_multiport = true
break
}
}
}
assert found_multiport
}
fn test_parse_iptables_complex_rate_limit() {
content := os.read_file(@VMODROOT + '/testdata/iptables_complex.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_iptables(content)!
mut found_rate_limited := false
for rule in rs.rules {
if rate := rule.criteria.limit_rate {
if rate.contains('3/minute') {
found_rate_limited = true
break
}
}
}
assert found_rate_limited
}
fn test_parse_iptables_complex_nat_table() {
content := os.read_file(@VMODROOT + '/testdata/iptables_complex.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_iptables(content)!
mut has_masquerade := false
for rule in rs.rules {
if rule.table == .nat && rule.action == .masquerade {
has_masquerade = true
break
}
}
assert has_masquerade
}
fn test_ip_to_u32_valid() {
result := models.ip_to_u32('192.168.1.1')!
assert result == (u32(192) << 24) | (u32(168) << 16) | (u32(1) << 8) | u32(1)
}
fn test_ip_to_u32_zeros() {
result := models.ip_to_u32('0.0.0.0')!
assert result == u32(0)
}
fn test_ip_to_u32_max() {
result := models.ip_to_u32('255.255.255.255')!
assert result == u32(0xFFFFFFFF)
}
fn test_ip_to_u32_invalid_octet() {
if _ := models.ip_to_u32('999.0.0.1') {
assert false
}
}
fn test_ip_to_u32_non_numeric() {
if _ := models.ip_to_u32('hello.world.foo.bar') {
assert false
}
}
fn test_ip_to_u32_too_few_octets() {
if _ := models.ip_to_u32('10.0.1') {
assert false
}
}
fn test_parse_nftables_complex_dnat() {
content := os.read_file(@VMODROOT + '/testdata/nftables_complex.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_nftables(content)!
mut found_dnat := false
for rule in rs.rules {
if rule.action == models.Action.dnat {
found_dnat = true
assert rule.target_args.contains('10.0.1.5')
break
}
}
assert found_dnat
}
fn test_parse_nftables_complex_masquerade() {
content := os.read_file(@VMODROOT + '/testdata/nftables_complex.rules') or {
panic('cannot read testdata: ${err}')
}
rs := parse_nftables(content)!
mut found_masq := false
for rule in rs.rules {
if rule.action == models.Action.masquerade {
found_masq = true
break
}
}
assert found_masq
}
fn test_parse_nftables_ipv6_saddr() {
content := 'table inet filter {\n chain input {\n ip6 saddr 2001:db8::/32 drop\n }\n}'
rs := parse_nftables(content)!
assert rs.rules.len == 1
src := rs.rules[0].criteria.source or { panic('expected source') }
assert src.address == '2001:db8::'
assert src.cidr == 32
}
fn test_parse_nftables_ipv6_daddr() {
content := 'table inet filter {\n chain input {\n ip6 daddr ::1/128 drop\n }\n}'
rs := parse_nftables(content)!
assert rs.rules.len == 1
dst := rs.rules[0].criteria.destination or { panic('expected destination') }
assert dst.address == '::1'
assert dst.cidr == 128
}
fn test_parse_iptables_goto() {
rs := parse_iptables('-A INPUT -p tcp --dport 80 -g MYCHAIN')!
assert rs.rules.len == 1
assert rs.rules[0].action == models.Action.jump
assert rs.rules[0].chain == 'INPUT'
assert rs.rules[0].criteria.protocol == .tcp
}
fn test_parse_iptables_goto_long_form() {
rs := parse_iptables('-A FORWARD -p udp --goto CUSTOM')!
assert rs.rules.len == 1
assert rs.rules[0].action == models.Action.jump
}

View File

@ -0,0 +1,14 @@
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -p tcp --dport 22 -j ACCEPT
-A INPUT -p tcp --dport 80 -j ACCEPT
-A INPUT -p tcp --dport 443 -j ACCEPT
-A INPUT -p icmp --icmp-type echo-request -j ACCEPT
-A INPUT -j LOG --log-prefix "DROPPED: "
-A INPUT -j DROP
COMMIT

View File

@ -0,0 +1,36 @@
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
-A OUTPUT -o lo -j ACCEPT
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -i eth0 -s 10.0.0.0/8 -j DROP
-A INPUT -i eth0 -s 172.16.0.0/12 -j DROP
-A INPUT -i eth0 -s 192.168.0.0/16 -j DROP
-A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -m limit --limit 3/minute --limit-burst 5 -j ACCEPT
-A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
-A INPUT -p tcp --dport 8080 -j ACCEPT
-A INPUT -p udp --dport 53 -j ACCEPT
-A INPUT -p tcp --dport 53 -j ACCEPT
-A INPUT -p udp --dport 123 -j ACCEPT
-A INPUT -p icmp --icmp-type echo-request -m limit --limit 1/second --limit-burst 5 -j ACCEPT
-A INPUT -p icmp --icmp-type echo-reply -j ACCEPT
-A INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT
-A INPUT -p icmp --icmp-type time-exceeded -j ACCEPT
-A INPUT -s 203.0.113.0/24 -p tcp --dport 3306 -j ACCEPT
-A INPUT -s 198.51.100.10 -p tcp --dport 5432 -j ACCEPT
-A INPUT -m limit --limit 5/minute -j LOG --log-prefix "DROPPED: "
-A INPUT -j DROP
-A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A FORWARD -j DROP
COMMIT
*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -o eth0 -j MASQUERADE
-A PREROUTING -i eth0 -p tcp --dport 8443 -j DNAT --to-destination 10.0.1.5:443
COMMIT

View File

@ -0,0 +1,16 @@
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -p tcp --dport 22 -j ACCEPT
-A INPUT -s 10.0.0.0/8 -p tcp --dport 22 -j ACCEPT
-A INPUT -p tcp --dport 80 -j ACCEPT
-A INPUT -p tcp --dport 80 -j ACCEPT
-A INPUT -s 192.168.1.0/24 -p tcp --dport 443 -j ACCEPT
-A INPUT -s 192.168.0.0/16 -p tcp --dport 443 -j DROP
-A INPUT -p tcp --dport 8080 -j ACCEPT
-A INPUT -p tcp -j DROP
-A INPUT -j DROP
COMMIT

View File

@ -0,0 +1,22 @@
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iifname "lo" accept
ct state established,related accept
ct state invalid drop
tcp dport 22 accept
tcp dport 80 accept
tcp dport 443 accept
icmp type echo-request accept
log prefix "DROPPED: " drop
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}

View File

@ -0,0 +1,54 @@
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iifname "lo" accept
ct state established,related accept
ct state invalid drop
iifname "eth0" ip saddr 10.0.0.0/8 drop
iifname "eth0" ip saddr 172.16.0.0/12 drop
iifname "eth0" ip saddr 192.168.0.0/16 drop
tcp dport 22 ct state new limit rate 3/minute burst 5 packets accept
tcp dport { 80, 443 } accept
tcp dport 8080 accept
udp dport 53 accept
tcp dport 53 accept
udp dport 123 accept
icmp type echo-request limit rate 1/second burst 5 packets accept
icmp type { echo-reply, destination-unreachable, time-exceeded } accept
ip saddr 203.0.113.0/24 tcp dport 3306 accept
ip saddr 198.51.100.10 tcp dport 5432 accept
limit rate 5/minute log prefix "DROPPED: "
drop
}
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
drop
}
chain output {
type filter hook output priority 0; policy accept;
}
}
table inet nat {
chain postrouting {
type nat hook postrouting priority 100;
oifname "eth0" masquerade
}
chain prerouting {
type nat hook prerouting priority -100;
iifname "eth0" tcp dport 8443 dnat to 10.0.1.5:443
}
}

View File

@ -0,0 +1,21 @@
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iifname "lo" accept
ct state established,related accept
tcp dport 22 accept
ip saddr 10.0.0.0/8 tcp dport 22 accept
tcp dport 80 accept
tcp dport 80 accept
ip saddr 192.168.1.0/24 tcp dport 443 accept
ip saddr 192.168.0.0/16 tcp dport 443 drop
tcp dport 8080 accept
ip protocol tcp drop
drop
}
}

View File

@ -0,0 +1,7 @@
Module {
name: 'fwrule'
description: 'Firewall rule parser, analyzer, and generator for iptables and nftables'
version: '1.0.0'
license: 'AGPL-3.0'
dependencies: []
}

@ -1 +1 @@
Subproject commit 0eec274fba59ddd373080ae961dd4497eaf2e454
Subproject commit ecbb534e85e8e381e6e89aece4b786db8f7ad172