From 22bb337320c5a22d91d8e4966350a8f764b60d97 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Thu, 26 Mar 2026 00:41:59 -0400 Subject: [PATCH] feat: complete project firewall-rule-engine --- .../beginner/firewall-rule-engine/.gitignore | 26 + .../beginner/firewall-rule-engine/Justfile | 85 ++ .../beginner/firewall-rule-engine/LICENSE | 661 +++++++++++++++ .../beginner/firewall-rule-engine/README.md | 87 ++ .../beginner/firewall-rule-engine/install.sh | 150 ++++ .../firewall-rule-engine/learn/00-OVERVIEW.md | 161 ++++ .../firewall-rule-engine/learn/01-CONCEPTS.md | 499 +++++++++++ .../learn/02-ARCHITECTURE.md | 516 ++++++++++++ .../learn/03-IMPLEMENTATION.md | 576 +++++++++++++ .../learn/04-CHALLENGES.md | 340 ++++++++ .../src/analyzer/analyzer_test.v | 778 ++++++++++++++++++ .../src/analyzer/conflict.v | 315 +++++++ .../src/analyzer/optimizer.v | 253 ++++++ .../firewall-rule-engine/src/config/config.v | 99 +++ .../src/display/display.v | 199 +++++ .../src/generator/generator.v | 300 +++++++ .../src/generator/generator_test.v | 486 +++++++++++ .../beginner/firewall-rule-engine/src/main.v | 242 ++++++ .../firewall-rule-engine/src/models/models.v | 291 +++++++ .../firewall-rule-engine/src/parser/common.v | 173 ++++ .../src/parser/iptables.v | 303 +++++++ .../src/parser/nftables.v | 358 ++++++++ .../src/parser/parser_test.v | 504 ++++++++++++ .../testdata/iptables_basic.rules | 14 + .../testdata/iptables_complex.rules | 36 + .../testdata/iptables_conflicts.rules | 16 + .../testdata/nftables_basic.rules | 22 + .../testdata/nftables_complex.rules | 54 ++ .../testdata/nftables_conflicts.rules | 21 + PROJECTS/beginner/firewall-rule-engine/v.mod | 7 + TEMPLATES/fullstack-template | 2 +- 31 files changed, 7573 insertions(+), 1 deletion(-) create mode 100644 PROJECTS/beginner/firewall-rule-engine/.gitignore create mode 100644 PROJECTS/beginner/firewall-rule-engine/Justfile create mode 100644 PROJECTS/beginner/firewall-rule-engine/LICENSE create mode 100644 PROJECTS/beginner/firewall-rule-engine/README.md create mode 100755 PROJECTS/beginner/firewall-rule-engine/install.sh create mode 100644 PROJECTS/beginner/firewall-rule-engine/learn/00-OVERVIEW.md create mode 100644 PROJECTS/beginner/firewall-rule-engine/learn/01-CONCEPTS.md create mode 100644 PROJECTS/beginner/firewall-rule-engine/learn/02-ARCHITECTURE.md create mode 100644 PROJECTS/beginner/firewall-rule-engine/learn/03-IMPLEMENTATION.md create mode 100644 PROJECTS/beginner/firewall-rule-engine/learn/04-CHALLENGES.md create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/analyzer/analyzer_test.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/analyzer/conflict.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/analyzer/optimizer.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/config/config.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/display/display.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/generator/generator.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/generator/generator_test.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/main.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/models/models.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/parser/common.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/parser/iptables.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/parser/nftables.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/src/parser/parser_test.v create mode 100644 PROJECTS/beginner/firewall-rule-engine/testdata/iptables_basic.rules create mode 100644 PROJECTS/beginner/firewall-rule-engine/testdata/iptables_complex.rules create mode 100644 PROJECTS/beginner/firewall-rule-engine/testdata/iptables_conflicts.rules create mode 100644 PROJECTS/beginner/firewall-rule-engine/testdata/nftables_basic.rules create mode 100644 PROJECTS/beginner/firewall-rule-engine/testdata/nftables_complex.rules create mode 100644 PROJECTS/beginner/firewall-rule-engine/testdata/nftables_conflicts.rules create mode 100644 PROJECTS/beginner/firewall-rule-engine/v.mod diff --git a/PROJECTS/beginner/firewall-rule-engine/.gitignore b/PROJECTS/beginner/firewall-rule-engine/.gitignore new file mode 100644 index 00000000..55bcd177 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/.gitignore @@ -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/ diff --git a/PROJECTS/beginner/firewall-rule-engine/Justfile b/PROJECTS/beginner/firewall-rule-engine/Justfile new file mode 100644 index 00000000..bbbc0c45 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/Justfile @@ -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" diff --git a/PROJECTS/beginner/firewall-rule-engine/LICENSE b/PROJECTS/beginner/firewall-rule-engine/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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 +. diff --git a/PROJECTS/beginner/firewall-rule-engine/README.md b/PROJECTS/beginner/firewall-rule-engine/README.md new file mode 100644 index 00000000..e6279e04 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/README.md @@ -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 ` | Parse and display a ruleset in table format | +| `fwrule analyze ` | Run conflict detection and optimization analysis | +| `fwrule optimize ` | Show optimization suggestions only | +| `fwrule harden [options]` | Generate a hardened ruleset from scratch | +| `fwrule export -f ` | Convert between iptables and nftables formats | +| `fwrule diff ` | 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 diff --git a/PROJECTS/beginner/firewall-rule-engine/install.sh b/PROJECTS/beginner/firewall-rule-engine/install.sh new file mode 100755 index 00000000..9fcc1272 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/install.sh @@ -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 "" diff --git a/PROJECTS/beginner/firewall-rule-engine/learn/00-OVERVIEW.md b/PROJECTS/beginner/firewall-rule-engine/learn/00-OVERVIEW.md new file mode 100644 index 00000000..04a533e1 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/learn/00-OVERVIEW.md @@ -0,0 +1,161 @@ + + +# 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 +``` diff --git a/PROJECTS/beginner/firewall-rule-engine/learn/01-CONCEPTS.md b/PROJECTS/beginner/firewall-rule-engine/learn/01-CONCEPTS.md new file mode 100644 index 00000000..ad949774 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/learn/01-CONCEPTS.md @@ -0,0 +1,499 @@ + + +# 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. diff --git a/PROJECTS/beginner/firewall-rule-engine/learn/02-ARCHITECTURE.md b/PROJECTS/beginner/firewall-rule-engine/learn/02-ARCHITECTURE.md new file mode 100644 index 00000000..d15afb7e --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/learn/02-ARCHITECTURE.md @@ -0,0 +1,516 @@ + + +# 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. diff --git a/PROJECTS/beginner/firewall-rule-engine/learn/03-IMPLEMENTATION.md b/PROJECTS/beginner/firewall-rule-engine/learn/03-IMPLEMENTATION.md new file mode 100644 index 00000000..2389af9e --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/learn/03-IMPLEMENTATION.md @@ -0,0 +1,576 @@ + + +# 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. diff --git a/PROJECTS/beginner/firewall-rule-engine/learn/04-CHALLENGES.md b/PROJECTS/beginner/firewall-rule-engine/learn/04-CHALLENGES.md new file mode 100644 index 00000000..4fa6b615 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/learn/04-CHALLENGES.md @@ -0,0 +1,340 @@ + + +# 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 `` and ``. 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//_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 diff --git a/PROJECTS/beginner/firewall-rule-engine/src/analyzer/analyzer_test.v b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/analyzer_test.v new file mode 100644 index 00000000..64363e3d --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/analyzer_test.v @@ -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 +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/analyzer/conflict.v b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/conflict.v new file mode 100644 index 00000000..da9313d1 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/conflict.v @@ -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 +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/analyzer/optimizer.v b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/optimizer.v new file mode 100644 index 00000000..a5195b79 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/analyzer/optimizer.v @@ -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 '*' +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/config/config.v b/PROJECTS/beginner/firewall-rule-engine/src/config/config.v new file mode 100644 index 00000000..a1cfacdf --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/config/config.v @@ -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 +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/display/display.v b/PROJECTS/beginner/firewall-rule-engine/src/display/display.v new file mode 100644 index 00000000..695cb98c --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/display/display.v @@ -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] + '...' +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/generator/generator.v b/PROJECTS/beginner/firewall-rule-engine/src/generator/generator.v new file mode 100644 index 00000000..05265656 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/generator/generator.v @@ -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(' ') +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/generator/generator_test.v b/PROJECTS/beginner/firewall-rule-engine/src/generator/generator_test.v new file mode 100644 index 00000000..225371c1 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/generator/generator_test.v @@ -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 +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/main.v b/PROJECTS/beginner/firewall-rule-engine/src/main.v new file mode 100644 index 00000000..7cc151a3 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/main.v @@ -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 ') + 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 ') + 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 ') + 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 --format ') + 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 ') + 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} [options]') + println('') + println('COMMANDS:') + println(' load Load and display a ruleset') + println(' analyze Run conflict detection and optimization analysis') + println(' optimize Show optimization suggestions') + println(' harden Generate a hardened ruleset') + println(' export Convert ruleset between iptables/nftables formats') + println(' diff 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)! } + } +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/models/models.v b/PROJECTS/beginner/firewall-rule-engine/src/models/models.v new file mode 100644 index 00000000..857640a8 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/models/models.v @@ -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 +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/parser/common.v b/PROJECTS/beginner/firewall-rule-engine/src/parser/common.v new file mode 100644 index 00000000..b9b71bd0 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/parser/common.v @@ -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') +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/parser/iptables.v b/PROJECTS/beginner/firewall-rule-engine/src/parser/iptables.v new file mode 100644 index 00000000..2bf64c65 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/parser/iptables.v @@ -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 +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/parser/nftables.v b/PROJECTS/beginner/firewall-rule-engine/src/parser/nftables.v new file mode 100644 index 00000000..4fe52b42 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/parser/nftables.v @@ -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 +} diff --git a/PROJECTS/beginner/firewall-rule-engine/src/parser/parser_test.v b/PROJECTS/beginner/firewall-rule-engine/src/parser/parser_test.v new file mode 100644 index 00000000..4e54a5ba --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/src/parser/parser_test.v @@ -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 +} diff --git a/PROJECTS/beginner/firewall-rule-engine/testdata/iptables_basic.rules b/PROJECTS/beginner/firewall-rule-engine/testdata/iptables_basic.rules new file mode 100644 index 00000000..0d787b33 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/testdata/iptables_basic.rules @@ -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 diff --git a/PROJECTS/beginner/firewall-rule-engine/testdata/iptables_complex.rules b/PROJECTS/beginner/firewall-rule-engine/testdata/iptables_complex.rules new file mode 100644 index 00000000..1db25a55 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/testdata/iptables_complex.rules @@ -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 diff --git a/PROJECTS/beginner/firewall-rule-engine/testdata/iptables_conflicts.rules b/PROJECTS/beginner/firewall-rule-engine/testdata/iptables_conflicts.rules new file mode 100644 index 00000000..83387d5d --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/testdata/iptables_conflicts.rules @@ -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 diff --git a/PROJECTS/beginner/firewall-rule-engine/testdata/nftables_basic.rules b/PROJECTS/beginner/firewall-rule-engine/testdata/nftables_basic.rules new file mode 100644 index 00000000..5ef6ff09 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/testdata/nftables_basic.rules @@ -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; + } +} diff --git a/PROJECTS/beginner/firewall-rule-engine/testdata/nftables_complex.rules b/PROJECTS/beginner/firewall-rule-engine/testdata/nftables_complex.rules new file mode 100644 index 00000000..6103c349 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/testdata/nftables_complex.rules @@ -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 + } +} diff --git a/PROJECTS/beginner/firewall-rule-engine/testdata/nftables_conflicts.rules b/PROJECTS/beginner/firewall-rule-engine/testdata/nftables_conflicts.rules new file mode 100644 index 00000000..2a9dac58 --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/testdata/nftables_conflicts.rules @@ -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 + } +} diff --git a/PROJECTS/beginner/firewall-rule-engine/v.mod b/PROJECTS/beginner/firewall-rule-engine/v.mod new file mode 100644 index 00000000..097ad35d --- /dev/null +++ b/PROJECTS/beginner/firewall-rule-engine/v.mod @@ -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: [] +} diff --git a/TEMPLATES/fullstack-template b/TEMPLATES/fullstack-template index 0eec274f..ecbb534e 160000 --- a/TEMPLATES/fullstack-template +++ b/TEMPLATES/fullstack-template @@ -1 +1 @@ -Subproject commit 0eec274fba59ddd373080ae961dd4497eaf2e454 +Subproject commit ecbb534e85e8e381e6e89aece4b786db8f7ad172