From d81fdbfda821f728ad0d586e8fb429a014506924 Mon Sep 17 00:00:00 2001 From: Mr Sleeps Date: Mon, 29 Jun 2026 17:57:01 +0100 Subject: [PATCH] Initial commit, DMARC checking support added --- LICENCE | 674 +++++++++++++++++ README.md | 7 + composer.json | 57 ++ .../2026_06_29_132729_add_dmarc_settings.php | 130 ++++ .../2026_06_29_141647_add_dmarc_checks.php | 43 ++ .../filament/modals/dmarc-details.blade.php | 140 ++++ .../vendor/dns-tools/dmarc/generate.blade.php | 14 + .../dns-tools/dmarc/view-page.blade.php | 90 +++ src/Console/Commands/CheckDmarcRecords.php | 73 ++ src/Dmarc/DmarcRecord.php | 714 ++++++++++++++++++ src/Dmarc/Enums/DmarcAlignment.php | 36 + src/Dmarc/Enums/DmarcPolicy.php | 30 + src/Dmarc/Enums/DmarcReporting.php | 44 ++ src/Dmarc/Enums/DmarcTesting.php | 19 + .../InvalidDmarcRecordException.php | 30 + src/Dmarc/Services/DmarcCheckService.php | 429 +++++++++++ .../OLD/Pages/Dmarc/GenerateDmarc.php | 316 ++++++++ src/Filament/OLD/Pages/Dmarc/ListDmarc.php | 192 +++++ src/Filament/OLD/Pages/Dmarc/ViewDmarc.php | 76 ++ .../OLD/Providers/DnsToolsPanelProvider.php | 27 + src/Filament/OLD/Resources/DmarcResource.php | 46 ++ src/Filament/Pages/ListDomains.php | 19 + src/Filament/Resources/DnsToolsResource.php | 180 +++++ src/Filament/Resources/Pages/ListDomains.php | 19 + .../Resources/Tables/DomainsTable.php | 258 +++++++ src/Models/DmarcCheck.php | 114 +++ src/Models/SystemDomains.php | 38 + src/Providers/DnsToolsPlugin.php | 57 ++ src/Providers/DnsToolsServiceProvider.php | 86 +++ src/Services/DnsToolsService.php | 61 ++ 30 files changed, 4019 insertions(+) create mode 100644 LICENCE create mode 100644 README.md create mode 100644 composer.json create mode 100644 database/migrations/2026_06_29_132729_add_dmarc_settings.php create mode 100644 database/migrations/2026_06_29_141647_add_dmarc_checks.php create mode 100644 resources/views/filament/modals/dmarc-details.blade.php create mode 100644 resources/views/vendor/dns-tools/dmarc/generate.blade.php create mode 100644 resources/views/vendor/dns-tools/dmarc/view-page.blade.php create mode 100644 src/Console/Commands/CheckDmarcRecords.php create mode 100644 src/Dmarc/DmarcRecord.php create mode 100644 src/Dmarc/Enums/DmarcAlignment.php create mode 100644 src/Dmarc/Enums/DmarcPolicy.php create mode 100644 src/Dmarc/Enums/DmarcReporting.php create mode 100644 src/Dmarc/Enums/DmarcTesting.php create mode 100644 src/Dmarc/Exceptions/InvalidDmarcRecordException.php create mode 100644 src/Dmarc/Services/DmarcCheckService.php create mode 100644 src/Filament/OLD/Pages/Dmarc/GenerateDmarc.php create mode 100644 src/Filament/OLD/Pages/Dmarc/ListDmarc.php create mode 100644 src/Filament/OLD/Pages/Dmarc/ViewDmarc.php create mode 100644 src/Filament/OLD/Providers/DnsToolsPanelProvider.php create mode 100644 src/Filament/OLD/Resources/DmarcResource.php create mode 100644 src/Filament/Pages/ListDomains.php create mode 100644 src/Filament/Resources/DnsToolsResource.php create mode 100644 src/Filament/Resources/Pages/ListDomains.php create mode 100644 src/Filament/Resources/Tables/DomainsTable.php create mode 100644 src/Models/DmarcCheck.php create mode 100644 src/Models/SystemDomains.php create mode 100644 src/Providers/DnsToolsPlugin.php create mode 100644 src/Providers/DnsToolsServiceProvider.php create mode 100644 src/Services/DnsToolsService.php diff --git a/LICENCE b/LICENCE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENCE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 00000000..68a007ab --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +![VExim Web UI Logo](https://raw.githubusercontent.com/MrSleeps/VExim-Web-UI/refs/heads/main/public/images/logo.svg) +# VExim Plugin DNS Tools +This plugin is designed to work with the [VExim Web UI](https://github.com/MrSleeps/VExim-Web-UI). + +It provides some useful dns tools, such as DKIM, DMARC and SPF checks. + +You need to install [VExim Web UI](https://github.com/MrSleeps/VExim-Web-UI) for this to work diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..18334400 --- /dev/null +++ b/composer.json @@ -0,0 +1,57 @@ +{ + "name": "mrsleeps/vexim-web-plugin-dnstools", + "description": "DNS tools plugin for VExim Web UI dns plugins.", + "type": "filament-plugin", + "license": "MIT", + "keywords": [ + "laravel", + "filament", + "dns", + "cloudflare", + "hosting" + ], + "homepage": "https://github.com/MrSleeps/vexim-web-plugin-dnstools", + "authors": [ + { + "name": "MrSleeps", + "homepage": "https://github.com/MrSleeps" + } + ], + "require": { + "php": "^8.4", + "filament/filament": "^5.0" + }, + "autoload": { + "psr-4": { + "VEximweb\\Plugin\\DnsTools\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "VEximweb\\Plugin\\DnsTools\\Providers\\DnsToolsServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "minimum-stability": "stable", + "prefer-stable": true, + "require-dev": { + "larastan/larastan": "^3.0", + "laravel/pint": "^1.0", + "nunomaduro/collision": "^8.0", + "orchestra/testbench": "^11.0", + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-arch": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0", + "pestphp/pest-plugin-livewire": "^4.0", + "rector/rector": "^2.0" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/database/migrations/2026_06_29_132729_add_dmarc_settings.php b/database/migrations/2026_06_29_132729_add_dmarc_settings.php new file mode 100644 index 00000000..10337af0 --- /dev/null +++ b/database/migrations/2026_06_29_132729_add_dmarc_settings.php @@ -0,0 +1,130 @@ + [ + 'value' => 'none', + 'type' => 'string', + 'description' => 'DMARC policy: none, quarantine, or reject', + ], + 'dmarc_subdomain_policy' => [ + 'value' => null, + 'type' => 'string', + 'description' => 'DMARC subdomain policy: none, quarantine, or reject', + ], + 'dmarc_adkim' => [ + 'value' => 'relaxed', + 'type' => 'string', + 'description' => 'DKIM alignment: relaxed or strict', + ], + 'dmarc_aspf' => [ + 'value' => 'relaxed', + 'type' => 'string', + 'description' => 'SPF alignment: relaxed or strict', + ], + 'dmarc_percentage' => [ + 'value' => 100, + 'type' => 'integer', + 'description' => 'Percentage of messages to apply policy to (0-100)', + ], + 'dmarc_report_interval' => [ + 'value' => 86400, + 'type' => 'integer', + 'description' => 'Reporting interval in seconds (default 24 hours)', + ], + 'dmarc_t' => [ + 'value' => 'n', + 'type' => 'string', + 'description' => 'Testing mode: y (yes) or n (no)', + ], + + // Reporting addresses + 'dmarc_rua' => [ + 'value' => [], + 'type' => 'json', + 'description' => 'Aggregate report email addresses', + ], + 'dmarc_ruf' => [ + 'value' => [], + 'type' => 'json', + 'description' => 'Forensic report email addresses', + ], + 'dmarc_reporting' => [ + 'value' => ['all'], + 'type' => 'json', + 'description' => 'Failure reporting options (fo)', + ], + + // Advanced settings + 'dmarc_np' => [ + 'value' => null, + 'type' => 'string', + 'description' => 'Non-existent subdomain policy', + ], + 'dmarc_psd' => [ + 'value' => null, + 'type' => 'string', + 'description' => 'Public suffix domain policy: y, n, or u', + ], + + // Email localparts (overrides config values) + 'dmarc_rua_localpart' => [ + 'value' => env('DMARC_RUA_LOCALPART', 'dmarc'), + 'type' => 'string', + 'description' => 'Custom local part for RUA reports', + ], + 'dmarc_ruf_localpart' => [ + 'value' => env('DMARC_RUF_LOCALPART', 'dmarc'), + 'type' => 'string', + 'description' => 'Custom local part for RUF reports', + ], + ]; + + foreach ($settings as $key => $config) { + // Check if setting already exists + if (!$repository->has($key)) { + $repository->set($key, $config['value'], $config['type'], $config['description']); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $repository = app(SettingRepository::class); + + $keys = [ + 'dmarc_policy', + 'dmarc_subdomain_policy', + 'dmarc_adkim', + 'dmarc_aspf', + 'dmarc_percentage', + 'dmarc_report_interval', + 'dmarc_t', + 'dmarc_rua', + 'dmarc_ruf', + 'dmarc_reporting', + 'dmarc_np', + 'dmarc_psd', + 'dmarc_rua_localpart', + 'dmarc_ruf_localpart', + ]; + + $repository->deleteMultiple($keys); + } +}; diff --git a/database/migrations/2026_06_29_141647_add_dmarc_checks.php b/database/migrations/2026_06_29_141647_add_dmarc_checks.php new file mode 100644 index 00000000..596b0ae2 --- /dev/null +++ b/database/migrations/2026_06_29_141647_add_dmarc_checks.php @@ -0,0 +1,43 @@ +id(); + $table->string('domain')->unique()->index(); + $table->unsignedBigInteger('domain_id')->nullable()->index(); + $table->text('record')->nullable(); + $table->string('policy')->nullable()->index(); + $table->string('subdomain_policy')->nullable(); + $table->string('adkim')->nullable(); + $table->string('aspf')->nullable(); + $table->json('rua')->nullable(); + $table->json('ruf')->nullable(); + $table->json('reporting')->nullable(); + $table->integer('percentage')->nullable(); + $table->integer('report_interval')->nullable(); + $table->string('np')->nullable(); + $table->string('psd')->nullable(); + $table->string('t')->nullable(); + $table->boolean('valid')->default(false)->index(); + $table->text('error_message')->nullable(); + $table->timestamp('last_checked_at')->nullable()->index(); + $table->timestamp('next_check_at')->nullable()->index(); + $table->timestamps(); + + // Add foreign key constraint if needed (optional) + // $table->foreign('domain_id')->references('domain_id')->on('domains')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('vw_dmarc_checks'); + } +}; \ No newline at end of file diff --git a/resources/views/filament/modals/dmarc-details.blade.php b/resources/views/filament/modals/dmarc-details.blade.php new file mode 100644 index 00000000..d0fba78e --- /dev/null +++ b/resources/views/filament/modals/dmarc-details.blade.php @@ -0,0 +1,140 @@ +
+ + {{-- Header --}} +
+ +
+

+ {{ $domain }} +

+ + @if($dmarc && $dmarc->valid) + + Valid + + @elseif($dmarc) + + Invalid + + @else + + Not Checked + + @endif +
+ + @if($dmarc && $dmarc->last_checked_at) +
+ Last checked
+ + {{ $dmarc->last_checked_at->format('Y-m-d H:i:s') }} + +
+ @endif +
+ + @if($dmarc) + + {{-- Error --}} + @if(!$dmarc->valid && $dmarc->error_message) +
+
+ Error +
+
+ {{ $dmarc->error_message }} +
+
+ @endif + + {{-- Record --}} + @if($dmarc->record) +
+
+ DMARC Record +
+ +
+ + {{ $dmarc->record }} + +
+
+ @endif + + {{-- Grid --}} +
+ +
+
Policy
+
+ {{ ucfirst($dmarc->policy ?? 'Not set') }} +
+
+ +
+
Subdomain Policy
+
+ {{ $dmarc->subdomain_policy ? ucfirst($dmarc->subdomain_policy) : 'Not set' }} +
+
+ +
+
DKIM Alignment
+
+ {{ $dmarc->adkim ? ucfirst($dmarc->adkim) : 'Not set' }} +
+
+ +
+
SPF Alignment
+
+ {{ $dmarc->aspf ? ucfirst($dmarc->aspf) : 'Not set' }} +
+
+ + @if($dmarc->percentage !== null) +
+
Percentage
+
+ {{ $dmarc->percentage }}% +
+
+ @endif + +
+ + {{-- RUA / RUF --}} + @if($dmarc->rua || $dmarc->ruf) +
+ + @if($dmarc->rua) +
+
Aggregate (RUA)
+
+ {{ implode(', ', $dmarc->rua) }} +
+
+ @endif + + @if($dmarc->ruf) +
+
Forensic (RUF)
+
+ {{ implode(', ', $dmarc->ruf) }} +
+
+ @endif + +
+ @endif + + @else + +
+ No DMARC record has been checked yet. +
+ + @endif + +
\ No newline at end of file diff --git a/resources/views/vendor/dns-tools/dmarc/generate.blade.php b/resources/views/vendor/dns-tools/dmarc/generate.blade.php new file mode 100644 index 00000000..07ee09cb --- /dev/null +++ b/resources/views/vendor/dns-tools/dmarc/generate.blade.php @@ -0,0 +1,14 @@ + +
+
+

+ Generate DMARC Record for {{ $record->domain }} +

+

+ Configure your DMARC policy below. The generated record will be saved and can be viewed in the DMARC management page. +

+
+ + {{ $this->form }} +
+
diff --git a/resources/views/vendor/dns-tools/dmarc/view-page.blade.php b/resources/views/vendor/dns-tools/dmarc/view-page.blade.php new file mode 100644 index 00000000..cacf34a6 --- /dev/null +++ b/resources/views/vendor/dns-tools/dmarc/view-page.blade.php @@ -0,0 +1,90 @@ + + @if($dmarc && $dmarc->valid) +
+
+
+

Domain

+

{{ $record->domain }}

+
+ +
+

Policy

+

+ + {{ $dmarc->getPolicyLabel() }} + + ({{ $dmarc->policy }}) +

+
+ +
+

Last Checked

+

{{ $dmarc->last_checked_at?->diffForHumans() ?? 'Never' }}

+
+
+ +
+
+

DKIM Alignment

+

{{ ucfirst($dmarc->adkim ?? 'Not set') }}

+
+ +
+

SPF Alignment

+

{{ ucfirst($dmarc->aspf ?? 'Not set') }}

+
+ +
+

Enforcement Percentage

+

{{ $dmarc->percentage ?? 100 }}%

+
+ +
+

Testing Mode

+

{{ $dmarc->t === 'y' ? '✅ On' : '❌ Off' }}

+
+
+ + @if($dmarc->rua || $dmarc->ruf) +
+

Reporting Addresses

+
+ @if($dmarc->rua) +

RUA: {{ implode(', ', $dmarc->rua) }}

+ @endif + @if($dmarc->ruf) +

RUF: {{ implode(', ', $dmarc->ruf) }}

+ @endif +
+
+ @endif + +
+

DNS Record

+
+ v=DMARC1; {{ $dmarc->record }} +
+
+

Next check: {{ $dmarc->next_check_at?->diffForHumans() ?? 'Not scheduled' }}

+
+
+
+ @else +
+
📡
+

No DMARC Record Found

+

+ Domain: {{ $record->domain }} +

+ @if($dmarc?->error_message) +

{{ $dmarc->error_message }}

+ @endif + +
+ @endif +
diff --git a/src/Console/Commands/CheckDmarcRecords.php b/src/Console/Commands/CheckDmarcRecords.php new file mode 100644 index 00000000..07dc7372 --- /dev/null +++ b/src/Console/Commands/CheckDmarcRecords.php @@ -0,0 +1,73 @@ +option('stats')) { + $stats = $service->getStats(); + + $this->info("DMARC Statistics:"); + $this->line("Total domains checked: {$stats['total']}"); + $this->line("Valid DMARC records: {$stats['valid']}"); + $this->line("No DMARC record: {$stats['no_record']}"); + $this->line("Invalid records: {$stats['invalid']}"); + + if (!empty($stats['policies'])) { + $this->line("\nPolicy Breakdown:"); + foreach ($stats['policies'] as $policy => $count) { + $label = match($policy) { + 'none' => 'Monitor', + 'quarantine' => 'Quarantine', + 'reject' => 'Reject', + default => $policy, + }; + $this->line(" {$label}: {$count}"); + } + } + return; + } + + if ($this->option('domain')) { + $domain = $this->option('domain'); + $this->info("Checking DMARC for: {$domain}"); + + $result = $service->checkDomain($domain); + + if ($result && $result->valid) { + $this->info("✓ DMARC record found!"); + $this->line("Policy: {$result->getPolicyLabel()}"); + $this->line("Record: {$result->record}"); + } else { + $this->error("✗ No valid DMARC record found"); + if ($result) { + $this->line("Error: {$result->error_message}"); + } + } + return; + } + + if ($this->option('all')) { + $this->info('Checking DMARC for all domains...'); + $service->checkAllDomains(); + $this->info('Done!'); + return; + } + + // Default: check domains that need updating + $this->info('Checking domains that need DMARC updates...'); + $service->checkDomainsNeedingUpdate(); + $this->info('Done!'); + } +} diff --git a/src/Dmarc/DmarcRecord.php b/src/Dmarc/DmarcRecord.php new file mode 100644 index 00000000..ecf48b2c --- /dev/null +++ b/src/Dmarc/DmarcRecord.php @@ -0,0 +1,714 @@ +version = $version; + $this->setPolicy($policy); + $this->setSubdomainPolicy($subdomainPolicy); + $this->rua($rua); + $this->ruf($ruf); + $this->setAdkim($adkim); + $this->setAspf($aspf); + $this->reporting($reporting); + $this->percentage($percentage); + $this->reportInterval($reportInterval); + $this->setNonExistentSubdomainPolicy($nonExistentSubdomainPolicy); + $this->publicSuffixDomainPolicy($publicSuffixDomainPolicy); + $this->setTestingMode($testingMode); + } + + /** + * Create a new DMARC record from an array + */ + public static function fromArray(array $data): static + { + return new static( + version: Arr::get($data, 'version', 'DMARC1'), + policy: Arr::get($data, 'policy'), + subdomainPolicy: Arr::get($data, 'subdomain_policy'), + rua: Arr::get($data, 'rua'), + ruf: Arr::get($data, 'ruf'), + adkim: Arr::get($data, 'adkim'), + aspf: Arr::get($data, 'aspf'), + reporting: Arr::get($data, 'reporting', []), + percentage: Arr::get($data, 'percentage'), + reportInterval: Arr::get($data, 'report_interval'), + nonExistentSubdomainPolicy: Arr::get($data, 'np'), + publicSuffixDomainPolicy: Arr::get($data, 'psd'), + testingMode: Arr::get($data, 't'), + ); + } + + /** + * Create from a DNS record string + */ + public static function fromString(string $record): static + { + $builder = new static; + + $properties = []; + + // Parse the DMARC record + foreach (explode(';', $record) as $part) { + $property = explode('=', trim($part)); + + if (count($property) !== 2) { + continue; + } + + $key = trim($property[0]); + $value = trim($property[1]); + + // Clean common issues: + // 1. Remove trailing periods (common in DNS zone files) + // 2. Remove surrounding quotes + // 3. Trim whitespace + $value = rtrim($value, '.'); + $value = trim($value, '"\''); + $value = trim($value); + + $properties[$key] = $value; + } + + // Validate required fields + if (!isset($properties['v'])) { + throw InvalidDmarcRecordException::missingKey('v', 'DMARC version is required'); + } + + if (!isset($properties['p'])) { + throw InvalidDmarcRecordException::missingKey('p', 'DMARC policy is required'); + } + + // Parse each property + foreach ($properties as $key => $value) { + match ($key) { + 'v' => $builder->version($value), + 'p' => $builder->policy($value), + 'sp' => $builder->subdomainPolicy($value), + 'rua' => $builder->rua($value), + 'ruf' => $builder->ruf($value), + 'adkim' => $builder->adkim($value), + 'aspf' => $builder->aspf($value), + 'fo' => $builder->reportingFromString($value), + 'pct' => $builder->percentage((int) $value), + 'ri' => $builder->reportInterval((int) $value), + 'np' => $builder->nonExistentSubdomainPolicy($value), + 'psd' => $builder->publicSuffixDomainPolicy($value), + 't' => $builder->testingMode($value), + default => null, + }; + } + + return $builder; + } + + /** + * Get the version + */ + public function version(?string $version): static + { + $this->version = $version; + return $this; + } + + /** + * Get/set the policy + */ + public function policy(DmarcPolicy|string|null $policy): static + { + $this->setPolicy($policy); + return $this; + } + + /** + * Get/set the subdomain policy + */ + public function subdomainPolicy(DmarcPolicy|string|null $policy): static + { + $this->setSubdomainPolicy($policy); + return $this; + } + + /** + * Get/set RUA (aggregate report) URIs + */ + public function rua(string|array|null $mailto): static + { + $this->rua = $this->normalizeReportUris($mailto); + return $this; + } + + /** + * Get/set RUF (forensic report) URIs + */ + public function ruf(string|array|null $mailto): static + { + $this->ruf = $this->normalizeReportUris($mailto); + return $this; + } + + /** + * Normalize report URIs + */ + protected function normalizeReportUris(string|array|null $value): ?string + { + if (is_null($value)) { + return null; + } + + $items = is_array($value) ? $value : explode(',', $value); + + $items = array_values(array_filter( + array_map('trim', $items), + fn (string $item): bool => $item !== '' + )); + + foreach ($items as $item) { + if (!str_starts_with($item, 'mailto:')) { + throw InvalidDmarcRecordException::invalidFormat( + 'mailto address should start with "mailto:"' + ); + } + } + + return $items === [] ? null : implode(',', $items); + } + + /** + * Get/set ADKIM (DKIM alignment) + */ + public function adkim(DmarcAlignment|string|null $value): static + { + $this->setAdkim($value); + return $this; + } + + /** + * Get/set ASPF (SPF alignment) + */ + public function aspf(DmarcAlignment|string|null $value): static + { + $this->setAspf($value); + return $this; + } + + /** + * Get/set reporting options (fo) + */ + public function reporting(array $values = []): static + { + $values = array_values(array_unique($values)); + + $processedValues = []; + + foreach ($values as $value) { + if ($value instanceof DmarcReporting) { + $processedValues[] = $value; + continue; + } + + // Convert to string and trim + $value = trim((string)$value); + + // Check if it's a short code (0, 1, d, s) + if (in_array($value, ['0', '1', 'd', 's'], true)) { + try { + $processedValues[] = DmarcReporting::fromShortCode($value); + continue; + } catch (\InvalidArgumentException $e) { + // Fall through to try from() method + } + } + + // Try to create from the full value (all, any, dkim, spf) + try { + $processedValues[] = DmarcReporting::from($value); + } catch (\ValueError $e) { + throw InvalidDmarcRecordException::invalidValue( + "Invalid reporting option: {$value}. Must be 0, 1, d, s, all, any, dkim, or spf" + ); + } + } + + // Check for mutually exclusive options (0 and 1 cannot be combined) + $hasAll = in_array(DmarcReporting::ALL, $processedValues, true); + $hasAny = in_array(DmarcReporting::ANY, $processedValues, true); + + if ($hasAll && $hasAny) { + throw InvalidDmarcRecordException::conflict( + 'Reporting options "all" (0) and "any" (1) are mutually exclusive.' + ); + } + + $this->reporting = $processedValues; + + return $this; + } + + /** + * Parse reporting options from string + */ + public function reportingFromString(string $value): static + { + // Remove whitespace and trim + $value = trim($value); + + // Clean the value - remove trailing periods and quotes + $value = rtrim($value, '.'); + $value = trim($value, '"\''); + $value = trim($value); + + // Check if it's a colon-separated list (e.g., "0:1:d:s") + if (str_contains($value, ':')) { + $options = array_map(function($item) { + $item = trim($item); + $item = rtrim($item, '.'); + $item = trim($item, '"\''); + return trim($item); + }, explode(':', $value)); + } else { + // Single value (e.g., "0", "1", "d", "s") + $options = [trim($value)]; + } + + // Filter out empty values + $options = array_filter($options, fn($v) => $v !== ''); + + // Convert numeric values to their string representations + $options = array_map(function($value) { + if (is_numeric($value)) { + return match((int)$value) { + 0 => '0', + 1 => '1', + default => (string)$value, + }; + } + return $value; + }, $options); + + $this->reporting($options); + return $this; + } + + /** + * Get/set percentage (pct) + */ + public function percentage(?int $percentage): static + { + if (!is_null($percentage) && ($percentage < 0 || $percentage > 100)) { + throw InvalidDmarcRecordException::invalidValue( + 'Percentage must be between 0 and 100' + ); + } + $this->percentage = $percentage; + return $this; + } + + /** + * Get/set reporting interval (ri) + */ + public function reportInterval(?int $interval): static + { + if (!is_null($interval) && $interval < 0) { + throw InvalidDmarcRecordException::invalidValue( + 'Reporting interval must be a positive integer' + ); + } + $this->reportInterval = $interval; + return $this; + } + + /** + * Get/set non-existent subdomain policy (np) + */ + public function nonExistentSubdomainPolicy(DmarcPolicy|string|null $policy): static + { + $this->setNonExistentSubdomainPolicy($policy); + return $this; + } + + /** + * Get/set public suffix domain policy (psd) + */ + public function publicSuffixDomainPolicy(?string $policy): static + { + if (!is_null($policy) && !in_array($policy, ['y', 'n', 'u', null], true)) { + throw InvalidDmarcRecordException::invalidValue( + 'PSD must be "y", "n", or "u"' + ); + } + $this->publicSuffixDomainPolicy = $policy; + return $this; + } + + /** + * Get/set testing mode (t) + */ + public function testingMode(DmarcTesting|string|null $testingMode): static + { + $this->setTestingMode($testingMode); + return $this; + } + + /** + * Convert to a DNS record string + */ + public function toDnsRecord(): string + { + $parts = []; + + if ($this->version) { + $parts[] = "v={$this->version}"; + } + + if ($this->policy) { + $parts[] = "p={$this->policy->value}"; + } + + if ($this->subdomainPolicy) { + $parts[] = "sp={$this->subdomainPolicy->value}"; + } + + if ($this->rua) { + $parts[] = "rua={$this->rua}"; + } + + if ($this->ruf) { + $parts[] = "ruf={$this->ruf}"; + } + + if ($this->adkim) { + $parts[] = "adkim={$this->adkim->getShortCode()}"; + } + + if ($this->aspf) { + $parts[] = "aspf={$this->aspf->getShortCode()}"; + } + + if (!empty($this->reporting)) { + $foParts = array_map( + fn(DmarcReporting $option) => $option->getShortCode(), + $this->reporting + ); + $parts[] = "fo=" . implode(':', $foParts); + } + + if (!is_null($this->percentage)) { + $parts[] = "pct={$this->percentage}"; + } + + if (!is_null($this->reportInterval)) { + $parts[] = "ri={$this->reportInterval}"; + } + + + if ($this->nonExistentSubdomainPolicy) { + $parts[] = "np={$this->nonExistentSubdomainPolicy->value}"; + } + + if ($this->publicSuffixDomainPolicy) { + $parts[] = "psd={$this->publicSuffixDomainPolicy}"; + } + + if ($this->testingMode) { + $parts[] = "t={$this->testingMode->value}"; + } + + return implode('; ', $parts); + } + + /** + * Get all validation rules for Filament forms + */ + public static function getValidationRules(): array + { + return [ + 'policy' => ['required', 'string', 'in:none,quarantine,reject'], + 'subdomain_policy' => ['nullable', 'string', 'in:none,quarantine,reject'], + 'rua' => ['nullable', 'array'], + 'rua.*' => ['string', 'starts_with:mailto:'], + 'ruf' => ['nullable', 'array'], + 'ruf.*' => ['string', 'starts_with:mailto:'], + 'adkim' => ['nullable', 'string', 'in:relaxed,strict'], + 'aspf' => ['nullable', 'string', 'in:relaxed,strict'], + 'reporting' => ['nullable', 'array'], + 'reporting.*' => ['string', 'in:all,any,dkim,spf'], + 'percentage' => ['nullable', 'integer', 'min:0', 'max:100'], + 'report_interval' => ['nullable', 'integer', 'min:0'], + 'np' => ['nullable', 'string', 'in:none,quarantine,reject'], + 'psd' => ['nullable', 'string', 'in:y,n,u'], + 't' => ['nullable', 'string', 'in:y,n'], + ]; + } + + /** + * Convert to array + */ + public function toArray(): array + { + return [ + 'version' => $this->version, + 'policy' => $this->policy?->value, + 'subdomain_policy' => $this->subdomainPolicy?->value, + 'rua' => $this->rua ? explode(',', $this->rua) : null, + 'ruf' => $this->ruf ? explode(',', $this->ruf) : null, + 'adkim' => $this->adkim?->value, + 'aspf' => $this->aspf?->value, + 'reporting' => array_map(fn($opt) => $opt->value, $this->reporting), + 'percentage' => $this->percentage, + 'report_interval' => $this->reportInterval, + 'np' => $this->nonExistentSubdomainPolicy?->value, + 'psd' => $this->publicSuffixDomainPolicy, + 't' => $this->testingMode?->value, + 'dns_record' => $this->toDnsRecord(), + ]; + } + + /** + * Convert to JSON + */ + public function toJson($options = 0): string + { + return json_encode($this->toArray(), $options); + } + + /** + * JSON serialize + */ + public function jsonSerialize(): mixed + { + return $this->toArray(); + } + + /** + * Magic method for string conversion + */ + public function __toString(): string + { + return $this->toDnsRecord(); + } + + // Private setter methods with validation + + private function setPolicy(DmarcPolicy|string|null $policy): void + { + if (is_null($policy)) { + $this->policy = null; + return; + } + + $this->policy = $policy instanceof DmarcPolicy + ? $policy + : DmarcPolicy::from($policy); + } + + private function setSubdomainPolicy(DmarcPolicy|string|null $policy): void + { + if (is_null($policy)) { + $this->subdomainPolicy = null; + return; + } + + $this->subdomainPolicy = $policy instanceof DmarcPolicy + ? $policy + : DmarcPolicy::from($policy); + } + + private function setAdkim(DmarcAlignment|string|null $value): void + { + if (is_null($value)) { + $this->adkim = null; + return; + } + + if ($value instanceof DmarcAlignment) { + $this->adkim = $value; + return; + } + + // Check if it's a short code (r or s) + if (in_array($value, ['r', 's'], true)) { + $this->adkim = DmarcAlignment::fromShortCode($value); + return; + } + + // Try to create from the string value directly + try { + $this->adkim = DmarcAlignment::from($value); + } catch (\ValueError $e) { + throw InvalidDmarcRecordException::invalidValue( + "Invalid ADKIM value: '{$value}'. Must be 'relaxed', 'strict', 'r', or 's'" + ); + } + } + + private function setAspf(DmarcAlignment|string|null $value): void + { + if (is_null($value)) { + $this->aspf = null; + return; + } + + if ($value instanceof DmarcAlignment) { + $this->aspf = $value; + return; + } + + // Check if it's a short code (r or s) + if (in_array($value, ['r', 's'], true)) { + $this->aspf = DmarcAlignment::fromShortCode($value); + return; + } + + // Try to create from the string value directly + try { + $this->aspf = DmarcAlignment::from($value); + } catch (\ValueError $e) { + throw InvalidDmarcRecordException::invalidValue( + "Invalid ASPF value: '{$value}'. Must be 'relaxed', 'strict', 'r', or 's'" + ); + } + } + + private function setNonExistentSubdomainPolicy(DmarcPolicy|string|null $policy): void + { + if (is_null($policy)) { + $this->nonExistentSubdomainPolicy = null; + return; + } + + $this->nonExistentSubdomainPolicy = $policy instanceof DmarcPolicy + ? $policy + : DmarcPolicy::from($policy); + } + + private function setTestingMode(DmarcTesting|string|null $testingMode): void + { + if (is_null($testingMode)) { + $this->testingMode = null; + return; + } + + $this->testingMode = $testingMode instanceof DmarcTesting + ? $testingMode + : DmarcTesting::from($testingMode); + } + + public function getVersion(): ?string + { + return $this->version; + } + + public function getPolicy(): ?DmarcPolicy + { + return $this->policy; + } + + public function getSubdomainPolicy(): ?DmarcPolicy + { + return $this->subdomainPolicy; + } + + public function getRua(): ?string + { + return $this->rua; + } + + public function getRuf(): ?string + { + return $this->ruf; + } + + public function getAdkim(): ?DmarcAlignment + { + return $this->adkim; + } + + public function getAspf(): ?DmarcAlignment + { + return $this->aspf; + } + + public function getReporting(): array + { + return $this->reporting; + } + + public function getPercentage(): ?int + { + return $this->percentage; + } + + public function getReportInterval(): ?int + { + return $this->reportInterval; + } + + public function getNonExistentSubdomainPolicy(): ?DmarcPolicy + { + return $this->nonExistentSubdomainPolicy; + } + + public function getPublicSuffixDomainPolicy(): ?string + { + return $this->publicSuffixDomainPolicy; + } + + public function getTestingMode(): ?DmarcTesting + { + return $this->testingMode; + } +} \ No newline at end of file diff --git a/src/Dmarc/Enums/DmarcAlignment.php b/src/Dmarc/Enums/DmarcAlignment.php new file mode 100644 index 00000000..94d02b66 --- /dev/null +++ b/src/Dmarc/Enums/DmarcAlignment.php @@ -0,0 +1,36 @@ + 'r', + self::STRICT => 's', + }; + } + + public function getDescription(): string + { + return match($this) { + self::RELAXED => 'Relaxed alignment (subdomains allowed)', + self::STRICT => 'Strict alignment (exact match required)', + }; + } + + public static function fromShortCode(string $code): self + { + return match($code) { + 'r' => self::RELAXED, + 's' => self::STRICT, + default => throw new \InvalidArgumentException("Invalid alignment code: {$code}"), + }; + } +} \ No newline at end of file diff --git a/src/Dmarc/Enums/DmarcPolicy.php b/src/Dmarc/Enums/DmarcPolicy.php new file mode 100644 index 00000000..4b9c7972 --- /dev/null +++ b/src/Dmarc/Enums/DmarcPolicy.php @@ -0,0 +1,30 @@ + 'No action, monitor only', + self::QUARANTINE => 'Mark as spam/quarantine', + self::REJECT => 'Reject the email outright', + }; + } + + public function getSeverity(): int + { + return match($this) { + self::NONE => 0, + self::QUARANTINE => 1, + self::REJECT => 2, + }; + } +} \ No newline at end of file diff --git a/src/Dmarc/Enums/DmarcReporting.php b/src/Dmarc/Enums/DmarcReporting.php new file mode 100644 index 00000000..83d14302 --- /dev/null +++ b/src/Dmarc/Enums/DmarcReporting.php @@ -0,0 +1,44 @@ + '0', + self::ANY => '1', + self::DKIM => 'd', + self::SPF => 's', + }; + } + + public function getDescription(): string + { + return match($this) { + self::ALL => 'Report all failures', + self::ANY => 'Report if either DKIM or SPF fails', + self::DKIM => 'Report only DKIM failures', + self::SPF => 'Report only SPF failures', + }; + } + + public static function fromShortCode(string $code): self + { + return match($code) { + '0' => self::ALL, + '1' => self::ANY, + 'd' => self::DKIM, + 's' => self::SPF, + default => throw new \InvalidArgumentException("Invalid reporting option code: {$code}"), + }; + } +} \ No newline at end of file diff --git a/src/Dmarc/Enums/DmarcTesting.php b/src/Dmarc/Enums/DmarcTesting.php new file mode 100644 index 00000000..b6ae47ba --- /dev/null +++ b/src/Dmarc/Enums/DmarcTesting.php @@ -0,0 +1,19 @@ + 'Testing mode (don\'t apply policy)', + self::NO => 'Normal mode (apply policy)', + }; + } +} \ No newline at end of file diff --git a/src/Dmarc/Exceptions/InvalidDmarcRecordException.php b/src/Dmarc/Exceptions/InvalidDmarcRecordException.php new file mode 100644 index 00000000..88274c98 --- /dev/null +++ b/src/Dmarc/Exceptions/InvalidDmarcRecordException.php @@ -0,0 +1,30 @@ + $domain, + 'domain_id' => $domainId, + 'timestamp' => now()->toDateTimeString() + ]); + + while ($attempt <= $maxAttempts) { + $attemptStart = microtime(true); + + try { + Log::debug('DMARC check attempt', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'max_attempts' => $maxAttempts + ]); + + // Add delay between attempts (except first) + if ($attempt > 1) { + $delay = 1; + Log::debug('Adding delay before retry', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'delay_seconds' => $delay + ]); + sleep($delay); + } + + // Increase DNS timeout for remote domains + $originalTimeout = ini_get('dns.timeout'); + $originalRetry = ini_get('dns.retry'); + + Log::debug('DNS settings before change', [ + 'domain' => $domain, + 'original_timeout' => $originalTimeout, + 'original_retry' => $originalRetry + ]); + + ini_set('dns.timeout', '10'); + ini_set('dns.retry', '5'); + + Log::debug('DNS settings after change', [ + 'domain' => $domain, + 'new_timeout' => ini_get('dns.timeout'), + 'new_retry' => ini_get('dns.retry') + ]); + + // Query DNS for DMARC record + $dnsStart = microtime(true); + $records = @dns_get_record("_dmarc.{$domain}", DNS_TXT); + $dnsDuration = microtime(true) - $dnsStart; + + // Restore original settings + if ($originalTimeout !== false) { + ini_set('dns.timeout', $originalTimeout); + } + if ($originalRetry !== false) { + ini_set('dns.retry', $originalRetry); + } + + Log::debug('DNS query completed', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'duration_ms' => round($dnsDuration * 1000, 2), + 'success' => $records !== false, + 'record_count' => $records ? count($records) : 0, + 'records_found' => $records ? array_column($records, 'txt') : [] + ]); + + // If DNS query failed or returned no records + if ($records === false) { + $lastError = 'DNS query returned false (error)'; + Log::warning('DNS query returned false', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'duration_ms' => round($dnsDuration * 1000, 2) + ]); + $attempt++; + continue; + } + + if (empty($records)) { + $lastError = 'No DMARC record found (empty response)'; + Log::warning('DNS query returned empty', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'duration_ms' => round($dnsDuration * 1000, 2) + ]); + $attempt++; + continue; + } + + // Find the DMARC record (starts with v=DMARC1) + $dmarcRecord = null; + foreach ($records as $record) { + $txt = $record['txt'] ?? ''; + if (str_starts_with(trim($txt), 'v=DMARC1')) { + $dmarcRecord = $txt; + Log::debug('Found DMARC record', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'record' => $dmarcRecord + ]); + break; + } + } + + if (!$dmarcRecord) { + $lastError = 'No valid DMARC record found (v=DMARC1 missing)'; + Log::warning('No DMARC record with v=DMARC1 found', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'records' => array_column($records, 'txt') + ]); + $attempt++; + continue; + } + + // Parse the DMARC record + Log::debug('Parsing DMARC record', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'record' => $dmarcRecord + ]); + + try { + $parsed = DmarcRecord::fromString($dmarcRecord); + } catch (InvalidDmarcRecordException $e) { + $lastError = 'Invalid DMARC record: ' . $e->getMessage(); + Log::warning('Invalid DMARC record', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'error' => $e->getMessage(), + 'record' => $dmarcRecord + ]); + $attempt++; + continue; + } + + // Save to cache + $totalDuration = microtime(true) - $startTime; + + Log::info('DMARC check successful', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'total_duration_ms' => round($totalDuration * 1000, 2), + 'dns_duration_ms' => round($dnsDuration * 1000, 2), + 'policy' => $parsed->getPolicy()?->value, + 'record' => $dmarcRecord + ]); + + return $this->saveSuccessfulCheck($domain, $domainId, $dmarcRecord, $parsed); + + } catch (InvalidDmarcRecordException $e) { + $lastError = 'Invalid DMARC record: ' . $e->getMessage(); + Log::warning('DMARC check attempt failed (InvalidDmarcRecordException)', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'error' => $e->getMessage() + ]); + $attempt++; + continue; + } catch (\Exception $e) { + $lastError = 'Error checking DMARC: ' . $e->getMessage(); + Log::warning('DMARC check attempt failed (Exception)', [ + 'domain' => $domain, + 'attempt' => $attempt, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + $attempt++; + continue; + } + } + + // All attempts failed + $totalDuration = microtime(true) - $startTime; + + Log::error('DMARC check failed after all attempts', [ + 'domain' => $domain, + 'attempts' => $attempt - 1, + 'total_duration_ms' => round($totalDuration * 1000, 2), + 'last_error' => $lastError, + 'timestamp' => now()->toDateTimeString() + ]); + + return $this->saveFailedCheck($domain, $domainId, $lastError ?? 'Unknown error after ' . ($attempt - 1) . ' attempts'); + } + + /** + * Check all domains from the domains table + */ + public function checkAllDomains(): void + { + Log::info('Starting DMARC check for all domains'); + + // Get domains from the domains table + $domains = \VEximweb\Core\Data\Models\Domain::where('enabled', true) + ->select('domain_id', 'domain') + ->get(); + + Log::info('Found domains to check', [ + 'total' => $domains->count() + ]); + + foreach ($domains as $domain) { + Log::debug('Checking domain', [ + 'domain' => $domain->domain, + 'domain_id' => $domain->domain_id + ]); + $this->checkDomain($domain->domain, $domain->domain_id); + } + + Log::info('Finished DMARC check for all domains'); + } + + /** + * Check domains that need checking (next_check_at <= now) + */ + public function checkDomainsNeedingUpdate(): void + { + Log::info('Starting DMARC check for domains needing update'); + + // First, get all domains from the domains table + $domains = \VEximweb\Core\Data\Models\Domain::where('enabled', true) + ->select('domain_id', 'domain') + ->get(); + + $checked = 0; + $skipped = 0; + + foreach ($domains as $domain) { + // Check if this domain needs updating + $cache = DmarcCheck::where('domain', $domain->domain)->first(); + + if (!$cache || ($cache->next_check_at && $cache->next_check_at <= now())) { + Log::debug('Domain needs update', [ + 'domain' => $domain->domain, + 'last_check' => $cache?->last_checked_at, + 'next_check' => $cache?->next_check_at + ]); + $this->checkDomain($domain->domain, $domain->domain_id); + $checked++; + } else { + Log::debug('Domain skipped (not due for update)', [ + 'domain' => $domain->domain, + 'next_check' => $cache->next_check_at + ]); + $skipped++; + } + } + + Log::info('Finished DMARC check for domains needing update', [ + 'checked' => $checked, + 'skipped' => $skipped, + 'total' => $domains->count() + ]); + } + + /** + * Save a successful check + */ + protected function saveSuccessfulCheck(string $domain, ?int $domainId, string $record, DmarcRecord $parsed): DmarcCheck + { + Log::debug('Saving successful check', [ + 'domain' => $domain, + 'policy' => $parsed->getPolicy()?->value + ]); + + $result = DmarcCheck::updateOrCreate( + ['domain' => $domain], + [ + 'domain_id' => $domainId, + 'record' => $record, + 'policy' => $parsed->getPolicy()?->value, + 'subdomain_policy' => $parsed->getSubdomainPolicy()?->value, + 'adkim' => $parsed->getAdkim()?->value, + 'aspf' => $parsed->getAspf()?->value, + 'rua' => $parsed->getRua() ? explode(',', $parsed->getRua()) : null, + 'ruf' => $parsed->getRuf() ? explode(',', $parsed->getRuf()) : null, + 'reporting' => $parsed->getReporting() ? array_map(fn($r) => $r->value, $parsed->getReporting()) : null, + 'percentage' => $parsed->getPercentage(), + 'report_interval' => $parsed->getReportInterval(), + 'np' => $parsed->getNonExistentSubdomainPolicy()?->value, + 'psd' => $parsed->getPublicSuffixDomainPolicy(), + 't' => $parsed->getTestingMode()?->value, + 'valid' => true, + 'error_message' => null, + 'last_checked_at' => now(), + 'next_check_at' => now()->addHours(24), + ] + ); + + Log::debug('Saved successful check', [ + 'domain' => $domain, + 'id' => $result->id, + 'valid' => $result->valid + ]); + + return $result; + } + + /** + * Save a failed check + */ + protected function saveFailedCheck(string $domain, ?int $domainId, string $error): DmarcCheck + { + Log::debug('Saving failed check', [ + 'domain' => $domain, + 'error' => $error + ]); + + $result = DmarcCheck::updateOrCreate( + ['domain' => $domain], + [ + 'domain_id' => $domainId, + 'valid' => false, + 'error_message' => $error, + 'last_checked_at' => now(), + 'next_check_at' => now()->addHours(6), + ] + ); + + Log::debug('Saved failed check', [ + 'domain' => $domain, + 'id' => $result->id, + 'valid' => $result->valid, + 'error' => $result->error_message + ]); + + return $result; + } + + /** + * Clear cache for a domain + */ + public function clearCache(string $domain): void + { + Log::info('Clearing DMARC cache', ['domain' => $domain]); + DmarcCheck::where('domain', $domain)->delete(); + Log::debug('DMARC cache cleared', ['domain' => $domain]); + } + + /** + * Get cached DMARC record for a domain + */ + public function getCached(string $domain): ?DmarcCheck + { + Log::debug('Getting cached DMARC record', ['domain' => $domain]); + + $cache = DmarcCheck::where('domain', $domain)->first(); + + if (!$cache) { + Log::debug('No cached record found, checking domain', ['domain' => $domain]); + return $this->checkDomain($domain); + } + + if ($cache->next_check_at && $cache->next_check_at <= now()) { + Log::debug('Cached record expired, rechecking', [ + 'domain' => $domain, + 'next_check' => $cache->next_check_at, + 'now' => now() + ]); + return $this->checkDomain($domain); + } + + Log::debug('Returning cached record', [ + 'domain' => $domain, + 'valid' => $cache->valid, + 'policy' => $cache->policy + ]); + + return $cache; + } + + /** + * Get statistics about DMARC records + */ + public function getStats(): array + { + Log::debug('Getting DMARC statistics'); + + $total = DmarcCheck::count(); + $valid = DmarcCheck::where('valid', true)->count(); + $invalid = DmarcCheck::where('valid', false)->count(); + + $policies = DmarcCheck::where('valid', true) + ->select('policy', \DB::raw('count(*) as count')) + ->groupBy('policy') + ->pluck('count', 'policy') + ->toArray(); + + $noRecord = DmarcCheck::where('valid', false) + ->where('error_message', 'No DMARC record found') + ->count(); + + $stats = [ + 'total' => $total, + 'valid' => $valid, + 'invalid' => $invalid, + 'policies' => $policies, + 'no_record' => $noRecord, + ]; + + Log::debug('DMARC statistics', $stats); + + return $stats; + } +} \ No newline at end of file diff --git a/src/Filament/OLD/Pages/Dmarc/GenerateDmarc.php b/src/Filament/OLD/Pages/Dmarc/GenerateDmarc.php new file mode 100644 index 00000000..123b46c8 --- /dev/null +++ b/src/Filament/OLD/Pages/Dmarc/GenerateDmarc.php @@ -0,0 +1,316 @@ +domain = Domain::find($record); + $this->existingDmarc = DmarcCheck::where('domain', $this->domain->domain)->first(); + } + + parent::mount(); + + if ($this->existingDmarc) { + $defaults = [ + 'policy' => $this->existingDmarc->policy ?? 'none', + 'subdomain_policy' => $this->existingDmarc->subdomain_policy ?? null, + 'adkim' => $this->existingDmarc->adkim ?? 'relaxed', + 'aspf' => $this->existingDmarc->aspf ?? 'relaxed', + 'reporting' => json_decode($this->existingDmarc->reporting ?? '["all"]', true), + 'percentage' => $this->existingDmarc->percentage ?? 100, + 't' => $this->existingDmarc->t ?? 'n', + ]; + + if ($this->existingDmarc->rua) { + $rua = json_decode($this->existingDmarc->rua, true); + if (is_array($rua)) { + $defaults['rua'] = collect($rua)->map(function ($item) { + return ['email' => str_replace('mailto:', '', $item)]; + })->toArray(); + } + } + + if ($this->existingDmarc->ruf) { + $ruf = json_decode($this->existingDmarc->ruf, true); + if (is_array($ruf)) { + $defaults['ruf'] = collect($ruf)->map(function ($item) { + return ['email' => str_replace('mailto:', '', $item)]; + })->toArray(); + } + } + + $this->form->fill($defaults); + } + } + + public function form(Form $form): Form + { + $service = app(DmarcCheckService::class); + $addresses = $service->getDefaultReportingAddresses($this->domain->domain ?? config('app.domain')); + + return $form + ->schema([ + Select::make('policy') + ->label('Policy') + ->options([ + 'none' => 'None (Monitor only)', + 'quarantine' => 'Quarantine (Mark as spam)', + 'reject' => 'Reject (Block delivery)', + ]) + ->required() + ->default('none') + ->live(), + + Select::make('subdomain_policy') + ->label('Subdomain Policy') + ->options([ + '' => 'Inherit from main policy', + 'none' => 'None (Monitor only)', + 'quarantine' => 'Quarantine (Mark as spam)', + 'reject' => 'Reject (Block delivery)', + ]) + ->nullable(), + + Repeater::make('rua') + ->label('Aggregate Reports (RUA)') + ->schema([ + TextInput::make('email') + ->label('Email') + ->email() + ->prefix('mailto:') + ->required(), + ]) + ->default(function () use ($addresses) { + if ($this->existingDmarc && $this->existingDmarc->rua) { + $rua = json_decode($this->existingDmarc->rua, true); + if (is_array($rua)) { + return collect($rua)->map(function ($item) { + return ['email' => str_replace('mailto:', '', $item)]; + })->toArray(); + } + } + return [['email' => str_replace('mailto:', '', $addresses['rua'])]]; + }) + ->addable() + ->deletable() + ->reorderable() + ->columnSpanFull(), + + Repeater::make('ruf') + ->label('Forensic Reports (RUF)') + ->schema([ + TextInput::make('email') + ->label('Email') + ->email() + ->prefix('mailto:') + ->required(), + ]) + ->default(function () use ($addresses) { + if ($this->existingDmarc && $this->existingDmarc->ruf) { + $ruf = json_decode($this->existingDmarc->ruf, true); + if (is_array($ruf)) { + return collect($ruf)->map(function ($item) { + return ['email' => str_replace('mailto:', '', $item)]; + })->toArray(); + } + } + return [['email' => str_replace('mailto:', '', $addresses['ruf'])]]; + }) + ->addable() + ->deletable() + ->reorderable() + ->columnSpanFull(), + + Select::make('adkim') + ->label('DKIM Alignment') + ->options([ + 'relaxed' => 'Relaxed (subdomains allowed)', + 'strict' => 'Strict (exact match)', + ]) + ->default('relaxed'), + + Select::make('aspf') + ->label('SPF Alignment') + ->options([ + 'relaxed' => 'Relaxed (subdomains allowed)', + 'strict' => 'Strict (exact match)', + ]) + ->default('relaxed'), + + Select::make('reporting') + ->label('Failure Reporting (FO)') + ->multiple() + ->options([ + 'all' => 'All failures', + 'any' => 'Any failure', + 'dkim' => 'DKIM failures only', + 'spf' => 'SPF failures only', + ]) + ->default(['all']), + + Slider::make('percentage') + ->label('Enforcement Percentage') + ->min(0) + ->max(100) + ->default(100), + + Select::make('t') + ->label('Testing Mode') + ->options([ + 'n' => 'Off (Apply policy)', + 'y' => 'On (Don\'t apply policy)', + ]) + ->default('n'), + + Placeholder::make('generated_record_preview') + ->label('Preview') + ->content(function ($get) { + try { + $settings = $this->prepareSettings($get); + $dmarcRecord = new DmarcRecord( + policy: DmarcPolicy::tryFrom($settings['policy']), + subdomainPolicy: $settings['subdomain_policy'] ? DmarcPolicy::tryFrom($settings['subdomain_policy']) : null, + rua: $settings['rua'], + ruf: $settings['ruf'], + adkim: DmarcAlignment::tryFrom($settings['adkim']), + aspf: DmarcAlignment::tryFrom($settings['aspf']), + reporting: array_map(fn($r) => DmarcReporting::tryFrom($r), $settings['reporting']), + percentage: (int)$settings['percentage'], + t: $settings['t'], + ); + + if ($this->domain) { + return "Name: _dmarc.{$this->domain->domain}\n" . + "Type: TXT\n" . + "Value: v=DMARC1; " . $dmarcRecord->toDnsRecord(); + } + return "Please select a domain to preview"; + } catch (\Exception $e) { + return "Error generating preview: " . $e->getMessage(); + } + }) + ->extraAttributes(['class' => 'font-mono text-sm bg-gray-50 dark:bg-gray-800 p-4 rounded']) + ->columnSpanFull(), + ]); + } + + protected function prepareSettings(array $data): array + { + return [ + 'policy' => $data['policy'] ?? 'none', + 'subdomain_policy' => $data['subdomain_policy'] ?? null, + 'rua' => collect($data['rua'] ?? []) + ->filter(fn($item) => !empty($item['email'])) + ->map(fn($item) => 'mailto:' . $item['email']) + ->values() + ->toArray(), + 'ruf' => collect($data['ruf'] ?? []) + ->filter(fn($item) => !empty($item['email'])) + ->map(fn($item) => 'mailto:' . $item['email']) + ->values() + ->toArray(), + 'adkim' => $data['adkim'] ?? 'relaxed', + 'aspf' => $data['aspf'] ?? 'relaxed', + 'reporting' => $data['reporting'] ?? ['all'], + 'percentage' => (int)($data['percentage'] ?? 100), + 't' => $data['t'] ?? 'n', + ]; + } + + protected function handleRecordCreation(array $data): \Illuminate\Database\Eloquent\Model + { + $settings = $this->prepareSettings($data); + + $dmarcRecord = new DmarcRecord( + policy: DmarcPolicy::tryFrom($settings['policy']), + subdomainPolicy: $settings['subdomain_policy'] ? DmarcPolicy::tryFrom($settings['subdomain_policy']) : null, + rua: $settings['rua'], + ruf: $settings['ruf'], + adkim: DmarcAlignment::tryFrom($settings['adkim']), + aspf: DmarcAlignment::tryFrom($settings['aspf']), + reporting: array_map(fn($r) => DmarcReporting::tryFrom($r), $settings['reporting']), + percentage: (int)$settings['percentage'], + t: $settings['t'], + ); + + $dnsRecord = 'v=DMARC1; ' . $dmarcRecord->toDnsRecord(); + + return DmarcCheck::updateOrCreate( + ['domain' => $this->domain->domain], + [ + 'domain_id' => $this->domain->domain_id, + 'record' => $dnsRecord, + 'policy' => $settings['policy'], + 'subdomain_policy' => $settings['subdomain_policy'], + 'adkim' => $settings['adkim'], + 'aspf' => $settings['aspf'], + 'rua' => json_encode($settings['rua']), + 'ruf' => json_encode($settings['ruf']), + 'reporting' => json_encode($settings['reporting']), + 'percentage' => (int)$settings['percentage'], + 't' => $settings['t'], + 'valid' => true, + 'error_message' => null, + 'last_checked_at' => now(), + 'next_check_at' => now()->addHours(24), + ] + ); + } + + protected function getCreatedNotificationTitle(): ?string + { + return "DMARC record generated for {$this->domain->domain}"; + } + + protected function getRedirectUrl(): string + { + return DmarcResource::getUrl('view', ['record' => $this->domain->getKey()]); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('back') + ->label('Back to View') + ->icon('heroicon-o-arrow-left') + ->url(DmarcResource::getUrl('view', ['record' => $this->domain->getKey()])), + + Action::make('back_to_list') + ->label('Back to List') + ->icon('heroicon-o-list-bullet') + ->url(DmarcResource::getUrl('index')), + ]; + } + + public function getTitle(): string + { + $action = $this->existingDmarc ? 'Update' : 'Generate'; + return "{$action} DMARC Record"; + } +} \ No newline at end of file diff --git a/src/Filament/OLD/Pages/Dmarc/ListDmarc.php b/src/Filament/OLD/Pages/Dmarc/ListDmarc.php new file mode 100644 index 00000000..b28446c6 --- /dev/null +++ b/src/Filament/OLD/Pages/Dmarc/ListDmarc.php @@ -0,0 +1,192 @@ +query($this->getTableQuery()) + ->columns([ + TextColumn::make('domain') + ->label('Domain') + ->searchable() + ->sortable() + ->size('lg') + ->weight('bold'), + + BadgeColumn::make('dmarc_status') + ->label('DMARC Status') + ->getStateUsing(function ($record) { + $dmarc = DmarcCheck::where('domain', $record->domain)->first(); + if (!$dmarc) { + return 'Not Checked'; + } + if (!$dmarc->valid) { + return 'No Record'; + } + return $dmarc->getPolicyLabel(); + }) + ->colors([ + 'success' => 'Monitor', + 'warning' => 'Quarantine', + 'danger' => 'Reject', + 'gray' => ['Not Checked', 'No Record'], + ]), + + BadgeColumn::make('dmarc_policy') + ->label('Policy') + ->getStateUsing(function ($record) { + $dmarc = DmarcCheck::where('domain', $record->domain)->first(); + return $dmarc?->policy ?? '-'; + }) + ->colors([ + 'success' => 'none', + 'warning' => 'quarantine', + 'danger' => 'reject', + 'gray' => '-', + ]), + + TextColumn::make('dmarc_last_checked') + ->label('Last Checked') + ->getStateUsing(function ($record) { + $dmarc = DmarcCheck::where('domain', $record->domain)->first(); + return $dmarc?->last_checked_at; + }) + ->since() + ->toggleable(), + + TextColumn::make('exim_users_count') + ->label('Accounts') + ->counts('eximUsers') + ->badge() + ->color('info'), + ]) + ->filters([ + \Filament\Tables\Filters\SelectFilter::make('dmarc_status') + ->label('DMARC Status') + ->options([ + 'has_record' => 'Has DMARC Record', + 'no_record' => 'No DMARC Record', + 'not_checked' => 'Not Checked', + ]) + ->query(function ($query, $data) { + if (empty($data['value'])) { + return $query; + } + + return match($data['value']) { + 'has_record' => $query->whereIn('domain', + DmarcCheck::where('valid', true)->pluck('domain')->toArray() + ), + 'no_record' => $query->whereIn('domain', + DmarcCheck::where('valid', false)->pluck('domain')->toArray() + ), + 'not_checked' => $query->whereNotIn('domain', + DmarcCheck::pluck('domain')->toArray() + ), + default => $query, + }; + }), + + \Filament\Tables\Filters\SelectFilter::make('policy') + ->label('Policy') + ->options([ + 'none' => 'Monitor', + 'quarantine' => 'Quarantine', + 'reject' => 'Reject', + ]) + ->query(function ($query, $data) { + if (empty($data['value'])) { + return $query; + } + $domains = DmarcCheck::where('policy', $data['value']) + ->where('valid', true) + ->pluck('domain') + ->toArray(); + return $query->whereIn('domain', $domains); + }), + ]) + ->actions([ + Action::make('view') + ->label('View DMARC') + ->icon('heroicon-o-eye') + ->color('info') + ->url(fn ($record): string => DmarcResource::getUrl('view', ['record' => $record])), + + Action::make('generate') + ->label('Generate Record') + ->icon('heroicon-o-document-plus') + ->color('success') + ->url(fn ($record): string => DmarcResource::getUrl('generate', ['record' => $record])), + + Action::make('check_dns') + ->label('Check DNS') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->action(function ($record) { + $service = app(DmarcCheckService::class); + $result = $service->checkDomain($record->domain, $record->domain_id); + + if ($result && $result->valid) { + Notification::make() + ->success() + ->title('DMARC Record Found') + ->body("Policy: {$result->getPolicyLabel()}") + ->send(); + } else { + Notification::make() + ->warning() + ->title('No DMARC Record Found') + ->body($result?->error_message ?? 'No DMARC record found in DNS') + ->send(); + } + }), + ]) + ->bulkActions([ + BulkActionGroup::make([ + Action::make('check_all_dmarc') + ->label('Check DMARC for Selected') + ->icon('heroicon-o-arrow-path') + ->action(function ($records) { + $service = app(DmarcCheckService::class); + $valid = 0; + + foreach ($records as $record) { + $result = $service->checkDomain($record->domain, $record->domain_id); + if ($result && $result->valid) { + $valid++; + } + } + + Notification::make() + ->success() + ->title('DMARC Check Complete') + ->body("Checked {$records->count()} domains, {$valid} have valid DMARC records") + ->send(); + }), + ]), + ]); + } +} diff --git a/src/Filament/OLD/Pages/Dmarc/ViewDmarc.php b/src/Filament/OLD/Pages/Dmarc/ViewDmarc.php new file mode 100644 index 00000000..8d0e17b3 --- /dev/null +++ b/src/Filament/OLD/Pages/Dmarc/ViewDmarc.php @@ -0,0 +1,76 @@ +dmarc = DmarcCheck::where('domain', $this->record->domain)->first(); + + // If no cache or invalid, check now + if (!$this->dmarc || !$this->dmarc->valid) { + $service = app(DmarcCheckService::class); + $this->dmarc = $service->checkDomain($this->record->domain, $this->record->domain_id); + } + + return $data; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('check_dns') + ->label('Check DNS Again') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->action(function () { + $service = app(DmarcCheckService::class); + $this->dmarc = $service->checkDomain($this->record->domain, $this->record->domain_id); + + Notification::make() + ->success() + ->title('DMARC Record Updated') + ->body($this->dmarc?->valid ? 'Record found and updated' : 'No valid record found') + ->send(); + + $this->refreshFormData(); + }), + + Action::make('generate') + ->label('Generate New Record') + ->icon('heroicon-o-document-plus') + ->color('success') + ->url(DmarcResource::getUrl('generate', ['record' => $this->record])), + + Action::make('back') + ->label('Back to List') + ->icon('heroicon-o-arrow-left') + ->url(DmarcResource::getUrl('index')), + ]; + } + + protected function getViewData(): array + { + return [ + 'dmarc' => $this->dmarc, + ]; + } + + public function getTitle(): string + { + return "DMARC Record for {$this->record->domain}"; + } +} diff --git a/src/Filament/OLD/Providers/DnsToolsPanelProvider.php b/src/Filament/OLD/Providers/DnsToolsPanelProvider.php new file mode 100644 index 00000000..095bbc72 --- /dev/null +++ b/src/Filament/OLD/Providers/DnsToolsPanelProvider.php @@ -0,0 +1,27 @@ +id('dns-tools') + ->path('dns-tools') + ->colors([ + 'primary' => Color::Blue, + ]) + ->resources([ + DmarcResource::class, + ]) + ->navigationGroups([ + 'DNS Tools', + ]); + } +} diff --git a/src/Filament/OLD/Resources/DmarcResource.php b/src/Filament/OLD/Resources/DmarcResource.php new file mode 100644 index 00000000..288580af --- /dev/null +++ b/src/Filament/OLD/Resources/DmarcResource.php @@ -0,0 +1,46 @@ + ListDmarc::route('/'), + 'view' => ViewDmarc::route('/{record}/view'), + 'generate' => GenerateDmarc::route('/{record}/generate'), + ]; + } +} \ No newline at end of file diff --git a/src/Filament/Pages/ListDomains.php b/src/Filament/Pages/ListDomains.php new file mode 100644 index 00000000..8734d760 --- /dev/null +++ b/src/Filament/Pages/ListDomains.php @@ -0,0 +1,19 @@ +boot(); + } + + $extensions = $discoveryService->getFormExtensions(); + + if (!empty($extensions)) { + $extensionComponents = []; + + foreach ($extensions as $extension) { + if (isset($extension['components']) && is_callable($extension['components'])) { + $components = $extension['components'](); + if (is_array($components)) { + $extensionComponents = array_merge($extensionComponents, $components); + } + } + } + + if (!empty($extensionComponents)) { + \Log::debug('Injecting DNS form extensions', [ + 'component_count' => count($extensionComponents), + 'extension_count' => count($extensions), + ]); + + $schema = $schema->components([ + ...$schema->getComponents(), + ...$extensionComponents, + ]); + } + } + } catch (\Exception $e) { + Log::debug('Could not load DNS form extensions: ' . $e->getMessage()); + } + } + + return $schema; + } + + public static function form_old(Schema $schema): Schema + { + return DomainForm::configure($schema); + } + + public static function table(Table $table): Table + { + return DomainsTable::configure($table); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + $user = auth()->user(); + + // If no user is logged in, return empty query + if (!$user) { + return $query->whereRaw('1 = 0'); + } + + // System admins can see all domains + if ($user->isSystemAdmin()) { + return $query; + } + + // Domain admins only see domains they're assigned to + if ($user->isDomainAdmin()) { + return $query->whereHas('administrators', function ($q) use ($user) { + $q->where('user_id', $user->id) + ->where('role', 'domain_admin'); + }); + } + + // Domain users shouldn't see any domains + return $query->whereRaw('1 = 0'); + } + + + + public static function getNavigationBadgeColor(): ?string + { + return 'primary'; + } + + public static function getNavigationBadgeTooltip(): ?string + { + $user = auth()->user(); + + if (!$user) { + return null; + } + + if ($user->isSystemAdmin()) { + return 'Total number of domains in the system'; + } + + if ($user->isDomainAdmin()) { + return 'Total number of domains you administer'; + } + + return null; + } + + // Hide navigation item for domain-users + public static function shouldRegisterNavigation(): bool + { + $user = auth()->user(); + + // Hide for domain-user + if (!$user || $user->isDomainUser()) { + return false; + } + + return true; + } + + public static function getPages(): array + { + return [ + 'index' => ListDomains::route('/'), + //'create' => CreateDomain::route('/create'), + //'edit' => EditDomain::route('/{record}/edit'), + ]; + } + + public static function getGloballySearchableAttributes(): array + { + return ['domain']; + } +} \ No newline at end of file diff --git a/src/Filament/Resources/Pages/ListDomains.php b/src/Filament/Resources/Pages/ListDomains.php new file mode 100644 index 00000000..001cba9d --- /dev/null +++ b/src/Filament/Resources/Pages/ListDomains.php @@ -0,0 +1,19 @@ +recordUrl(null) + ->columns([ + TextColumn::make('domain') + ->searchable() + ->sortable(), + + IconColumn::make('enabled') + ->boolean() + ->sortable(), + + IconColumn::make('dkim.enabled') + ->label('DKIM') + ->boolean() + ->trueIcon('heroicon-o-key') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('gray') + ->tooltip(function ($record): string { + if ($record->dkim && $record->dkim->enabled) { + return "DKIM active (selector: {$record->dkim->selector})"; + } elseif ($record->dkim && !$record->dkim->enabled) { + return "DKIM keys exist but are disabled"; + } else { + return "No DKIM keys configured"; + } + }), + + IconColumn::make('dmarccheck.valid') + ->label('DMARC') + ->boolean() + ->trueIcon('heroicon-o-shield-check') + ->falseIcon('heroicon-o-exclamation-triangle') + ->trueColor('success') + ->falseColor('danger') + ->tooltip(function ($record): string { + $dmarc = $record->dmarcCheck; + + if (!$dmarc) { + return 'DMARC not checked yet'; + } + + if ($dmarc->valid) { + return "DMARC valid (Policy: {$dmarc->getPolicyLabel()})"; + } else { + return "DMARC invalid: {$dmarc->error_message}"; + } + }), + ]) + ->filters([ + SelectFilter::make('enabled') + ->options([ + '1' => 'Enabled', + '0' => 'Disabled', + ]), + + SelectFilter::make('dkim_status') + ->label('DKIM Status') + ->options([ + 'has_dkim' => 'Has DKIM (Active)', + 'has_disabled' => 'Has DKIM (Disabled)', + 'no_dkim' => 'No DKIM', + ]) + ->query(function (Builder $query, array $data): Builder { + if ($data['value'] === 'has_dkim') { + return $query->whereHas('dkim', function ($q) { + $q->where('enabled', true); + }); + } + if ($data['value'] === 'has_disabled') { + return $query->whereHas('dkim', function ($q) { + $q->where('enabled', false); + }); + } + if ($data['value'] === 'no_dkim') { + return $query->whereDoesntHave('dkim'); + } + return $query; + }), + + // DMARC Status Filter + SelectFilter::make('dmarc_status') + ->label('DMARC Status') + ->options([ + 'valid' => 'Valid DMARC', + 'invalid' => 'Invalid DMARC', + 'not_checked' => 'Not Checked', + ]) + ->query(function (Builder $query, array $data): Builder { + if ($data['value'] === 'valid') { + return $query->whereHas('dmarcCheck', function ($q) { + $q->where('valid', true); + }); + } + if ($data['value'] === 'invalid') { + return $query->whereHas('dmarcCheck', function ($q) { + $q->where('valid', false); + }); + } + if ($data['value'] === 'not_checked') { + return $query->whereDoesntHave('dmarcCheck'); + } + return $query; + }), + + // DMARC Policy Filter + SelectFilter::make('dmarc_policy') + ->label('DMARC Policy') + ->options([ + 'none' => 'Monitor (none)', + 'quarantine' => 'Quarantine', + 'reject' => 'Reject', + ]) + ->query(function (Builder $query, array $data): Builder { + if ($data['value']) { + return $query->whereHas('dmarcCheck', function ($q) use ($data) { + $q->where('policy', $data['value']) + ->where('valid', true); + }); + } + return $query; + }), + ]) + + ->recordActions([ + Action::make('checkDmarc') + ->icon(Heroicon::ArrowPath) + ->label('') + ->tooltip('Check DMARC record now') + ->color('info') + ->action(function ($record) { + try { + $service = app(\VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService::class); + $result = $service->checkDomain($record->domain, $record->domain_id); + + if ($result && $result->valid) { + Notification::make() + ->title('DMARC check completed') + ->body("Valid DMARC record found for {$record->domain}") + ->success() + ->send(); + } else { + $error = $result ? $result->error_message : 'Unknown error'; + Notification::make() + ->title('DMARC check completed') + ->body("No valid DMARC record for {$record->domain}: {$error}") + ->warning() + ->send(); + } + } catch (\Exception $e) { + Notification::make() + ->title('DMARC check failed') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + + + //EditAction::make(), + ActionGroup::make([ + EditAction::make(), + EditAction::make(), + EditAction::make(), + Action::make('viewDmarc') + ->label('DMARC Details') + ->modalHeading('DMARC Record Details') + ->modalSubheading(fn ($record) => $record->domain) + + ->modalContent(fn ($record) => view( + 'dns-tools::filament.modals.dmarc-details', + [ + 'domain' => $record->domain, + 'dmarc' => $record->dmarcCheck, + 'record' => $record, + ] + )) + + ->modalWidth('3xl') + + ->modalActions([ + Action::make('checkAgain') + ->label('Check Again') + ->action(function ($record) { + $service = app(\VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService::class); + $service->checkDomain($record->domain, $record->domain_id); + + Notification::make() + ->title('DMARC check completed') + ->success() + ->send(); + }), + + Action::make('close') + ->label('Close') + ->close(), + ]), + ]), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + // Add bulk action to check DMARC for selected domains + Action::make('checkSelectedDmarc') + ->label('Check DMARC for selected') + ->icon(Heroicon::ArrowPath) + ->color('info') + ->requiresConfirmation() + ->action(function ($records) { + $service = app(\VEximweb\Plugin\DnsTools\Dmarc\Services\DmarcCheckService::class); + $count = 0; + $valid = 0; + + foreach ($records as $record) { + $result = $service->checkDomain($record->domain, $record->domain_id); + $count++; + if ($result && $result->valid) { + $valid++; + } + } + + Notification::make() + ->title("DMARC check completed for {$count} domains") + ->body("{$valid} domains have valid DMARC records") + ->success() + ->send(); + }), + ]); + } +} \ No newline at end of file diff --git a/src/Models/DmarcCheck.php b/src/Models/DmarcCheck.php new file mode 100644 index 00000000..04f2ce3e --- /dev/null +++ b/src/Models/DmarcCheck.php @@ -0,0 +1,114 @@ + 'boolean', + 'last_checked_at' => 'datetime', + 'next_check_at' => 'datetime', + 'percentage' => 'integer', + 'report_interval' => 'integer', + 'rua' => 'array', + 'ruf' => 'array', + 'reporting' => 'array', + ]; + + /** + * Get the domain this DMARC check belongs to (optional relation) + */ + public function domain(): BelongsTo + { + return $this->belongsTo(\VEximweb\Core\Data\Models\Domain::class, 'domain_id', 'domain_id'); + } + + /** + * Check if this domain has a DMARC record + */ + public function hasRecord(): bool + { + return $this->valid && !empty($this->record); + } + + /** + * Get the DMARC record as a string + */ + public function getRecordString(): ?string + { + return $this->record; + } + + /** + * Get the policy with a nice label + */ + public function getPolicyLabel(): string + { + $labels = [ + 'none' => 'Monitor', + 'quarantine' => 'Quarantine', + 'reject' => 'Reject', + ]; + + return $labels[$this->policy] ?? 'Unknown'; + } + + /** + * Get the policy with a color + */ + public function getPolicyColor(): string + { + return match($this->policy) { + 'none' => 'success', + 'quarantine' => 'warning', + 'reject' => 'danger', + default => 'gray', + }; + } + + /** + * Get policy icon + */ + public function getPolicyIcon(): string + { + return match($this->policy) { + 'none' => 'heroicon-o-eye', + 'quarantine' => 'heroicon-o-shield-exclamation', + 'reject' => 'heroicon-o-x-circle', + default => 'heroicon-o-question-mark-circle', + }; + } + + +} \ No newline at end of file diff --git a/src/Models/SystemDomains.php b/src/Models/SystemDomains.php new file mode 100644 index 00000000..2c435b0a --- /dev/null +++ b/src/Models/SystemDomains.php @@ -0,0 +1,38 @@ +hasOne(DmarcCheck::class, 'domain_id', 'domain_id'); + } + + /** + * Check if domain has a valid DMARC record + */ + public function hasValidDmarc(): bool + { + $dmarc = $this->dmarcCheck()->first(); + return $dmarc && $dmarc->valid; + } + + /** + * Get DMARC status + */ + public function getDmarcStatus(): string + { + $dmarc = $this->dmarcCheck()->first(); + if (!$dmarc) { + return 'not_checked'; + } + return $dmarc->valid ? 'valid' : 'invalid'; + } +} \ No newline at end of file diff --git a/src/Providers/DnsToolsPlugin.php b/src/Providers/DnsToolsPlugin.php new file mode 100644 index 00000000..d1316367 --- /dev/null +++ b/src/Providers/DnsToolsPlugin.php @@ -0,0 +1,57 @@ +resources([ + DnsToolsResource::class, + ]); + + $widgetPath = __DIR__ . '/Filament/Widgets'; + if (is_dir($widgetPath)) { + $widgetClasses = $this->discoverWidgets($widgetPath); + $panel->widgets($widgetClasses); + } + + + } + + public function boot(Panel $panel): void {} + + protected function discoverWidgets(string $path): array + { + $widgets = []; + + /* + $files = File::allFiles($path); + + foreach ($files as $file) { + $class = 'VEximweb\\Plugin\\DMARC\\Filament\\Widgets\\' . $file->getFilenameWithoutExtension(); + if (class_exists($class)) { + $widgets[] = $class; + } + } + + */ + + return $widgets; + } +} diff --git a/src/Providers/DnsToolsServiceProvider.php b/src/Providers/DnsToolsServiceProvider.php new file mode 100644 index 00000000..241953e2 --- /dev/null +++ b/src/Providers/DnsToolsServiceProvider.php @@ -0,0 +1,86 @@ +mergeConfigFrom( + __DIR__ . '/../../config/dmarc.php', + 'dmarc' + ); + */ + + // Register DMARC record service + $this->app->singleton('dmarc.record.service', function ($app) { + // Try to get setting repository from container + $settingRepository = null; + try { + if ($app->has('VEximweb\Core\Data\Repositories\Interfaces\SettingRepositoryInterface')) { + $settingRepository = $app->make('VEximweb\Core\Data\Repositories\Interfaces\SettingRepositoryInterface'); + } + } catch (\Exception $e) { + // Setting repository not available, continue without it + } + + return new DmarcRecordService($settingRepository); + }); + + // Register main service + $this->app->singleton('dns-tools.service', function ($app) { + return new DnsToolsService(); + }); + + Panel::configureUsing(function (Panel $panel) { + $panel->plugin(DnsToolsPlugin::make()); + }); + + //$this->app->register(DnsToolsPanelProvider::class); + } + + public function boot(): void + { + $this->publishes([ + __DIR__ . '/../../config/dmarc.php' => config_path('dmarc.php'), + ], 'dmarc-config'); + + // Load migrations + $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + + // Load routes + // $this->loadRoutesFrom(__DIR__ . '/../../routes/web.php'); + + // Load views + $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'dns-tools'); + + // Load translations + //$this->loadTranslationsFrom(__DIR__ . '/../../resources/lang', 'dns-tools'); + + + if (class_exists(\Filament\Panel::class)) { + // The resource will be auto-discovered if you use: + // $panel->discoverResources() in your panel provider + // Or register it manually: + \Filament\Facades\Filament::registerResources([ + \VEximweb\Plugin\DnsTools\Filament\Resources\DnsToolsResource::class, + ]); + } + + if ($this->app->runningInConsole()) { + $this->commands([ + \VEximweb\Plugin\DnsTools\Console\Commands\CheckDmarcRecords::class, + ]); + } + } +} \ No newline at end of file diff --git a/src/Services/DnsToolsService.php b/src/Services/DnsToolsService.php new file mode 100644 index 00000000..fe6be680 --- /dev/null +++ b/src/Services/DnsToolsService.php @@ -0,0 +1,61 @@ + $b['pri']; + }); + + return $mxRecords; + } catch (\Exception $e) { + Log::error("Error checking MX records for {$domain}: " . $e->getMessage()); + return null; + } + } + + /** + * Check if domain has valid MX records + */ + public function hasValidMxRecord(string $domain): bool + { + $records = $this->checkMxRecord($domain); + return !empty($records); + } + + function getSPFRecord(string $domain) { + $dnsRecords = dns_get_record($domain, DNS_TXT); + + if (!$dnsRecords) { + return "Failed to fetch DNS records."; + } + + foreach ($dnsRecords as $record) { + if (isset($record['txt']) && stripos($record['txt'], 'v=spf1') === 0) { + return $record['txt']; + } + } + + return "No SPF record found."; + } +} \ No newline at end of file