├── .editorconfig ├── .github └── FUNDING.yml ├── .gitignore ├── .htaccess ├── .php-cs-fixer.php ├── API_ENDPOINTS.md ├── INSTALL.md ├── LICENSE ├── README.md ├── backend ├── api │ └── Api.php ├── cli.php ├── common │ ├── Config.php │ ├── Debug.php │ └── Import.php ├── datasources │ ├── Akumuli.php │ ├── Datasource.php │ └── Rrd.php ├── index.php ├── listen.php ├── processor │ ├── Nfdump.php │ └── Processor.php ├── settings │ └── settings.php.dist └── vendor │ └── ProgressBar.php ├── composer.json ├── composer.lock ├── frontend ├── css │ ├── bootstrap.min.css │ ├── bootstrap.min.css.map │ ├── dygraph.css │ ├── footable.bootstrap.min.css │ ├── ion.rangeSlider.css │ ├── nfsen-ng.css │ └── sprite-skin-nice.png ├── index.html └── js │ ├── bootstrap.min.js │ ├── dygraph.min.js │ ├── footable.min.js │ ├── ion.rangeSlider.min.js │ ├── jquery.min.js │ ├── nfsen-ng.js │ └── popper.min.js └── psalm.xml /.editorconfig: -------------------------------------------------------------------------------- 1 | # Drupal editor configuration normalization 2 | # @see http://editorconfig.org/ 3 | 4 | # This is the top-most .editorconfig file; do not search in parent directories. 5 | root = true 6 | 7 | # All files. 8 | [*] 9 | end_of_line = LF 10 | indent_style = space 11 | indent_size = 4 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: mbolli 4 | custom: https://paypal.me/bolli 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | backend/settings/settings.php 3 | backend/datasources/data/ 4 | backend/cli/nfsen-ng.log 5 | backend/cli/nfsen-ng.pid 6 | profiles-data/ 7 | vendor/ 8 | *.cache 9 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | 2 | ServerSignature Off 3 | 4 | 5 | RewriteEngine On 6 | RewriteCond %{REQUEST_FILENAME} !-f 7 | RewriteCond %{REQUEST_FILENAME} !-d 8 | RewriteRule ^api/(.*)$ backend/index.php?request=$1 [QSA,NC,L] 9 | RewriteRule ^$ frontend [L] 10 | 11 | 12 | 13 | AddOutputFilterByType DEFLATE text/plain text/html 14 | AddOutputFilterByType DEFLATE image/svg+xml 15 | AddOutputFilterByType DEFLATE text/css 16 | AddOutputFilterByType DEFLATE text/json application/json 17 | AddOutputFilterByType DEFLATE application/javascript application/x-javascript text/x-component 18 | 19 | # Drop problematic browsers 20 | BrowserMatch ^Mozilla/4 gzip-only-text/html 21 | BrowserMatch ^Mozilla/4\.0[678] no-gzip 22 | BrowserMatch \bMSI[E] !no-gzip !gzip-only-text/html 23 | 24 | # Make sure proxies don't deliver the wrong content 25 | 26 | Header append Vary User-Agent env=!dont-vary 27 | 28 | 29 | 30 | 31 | 32 | Header append Vary Accept-Encoding 33 | 34 | 35 | 36 | 37 | mod_gzip_on Yes 38 | mod_gzip_dechunk Yes 39 | mod_gzip_item_include file \.(html?|txt|css|json|php)$ 40 | mod_gzip_item_include handler ^cgi-script$ 41 | mod_gzip_item_include mime ^text/.* 42 | mod_gzip_item_include mime ^application/x-javascript.* 43 | mod_gzip_item_include mime ^application/json 44 | mod_gzip_item_exclude mime ^image/.* 45 | mod_gzip_item_exclude rspheader ^Content-Encoding:.*gzip.* 46 | 47 | 48 | 49 | Header set Connection keep-alive 50 | 51 | 52 | 53 | # enable the directives - assuming they're not enabled globally 54 | ExpiresActive on 55 | 56 | ExpiresByType text/html "access plus 1 year" 57 | 58 | # send an Expires: header for each of these mimetypes (as defined by server) 59 | ExpiresByType image/png "access plus 1 year" 60 | 61 | # css may change a bit sometimes, so define shorter expiration 62 | ExpiresByType text/css "access plus 3 months" 63 | 64 | # libraries won't change much 65 | ExpiresByType application/javascript "access plus 1 year" 66 | ExpiresByType application/x-javascript "access plus 1 year" 67 | 68 | 69 | 70 | # ModPagespeed on 71 | 72 | 73 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 9 | ->setRules([ 10 | '@PhpCsFixer' => true, 11 | '@PhpCsFixer:risky' => true, 12 | '@PHP80Migration:risky' => true, 13 | '@PHP82Migration' => true, 14 | '@Symfony' => true, 15 | '@Symfony:risky' => true, 16 | 'concat_space' => ['spacing' => 'one'], 17 | 'control_structure_continuation_position' => ['position' => 'same_line'], 18 | 'curly_braces_position' => ['classes_opening_brace' => 'same_line', 'functions_opening_brace' => 'same_line'], 19 | 'declare_strict_types' => false, 20 | 'mb_str_functions' => true, 21 | 'nullable_type_declaration_for_default_null_value' => true, 22 | 'operator_linebreak' => true, 23 | 'phpdoc_to_comment' => ['ignored_tags' => ['var'], 'allow_before_return_statement' => true], 24 | 'single_line_empty_body' => true, 25 | 'string_implicit_backslashes' => ['double_quoted' => 'escape', 'single_quoted' => 'ignore', 'heredoc' => 'escape'], 26 | 'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false], 27 | ]) 28 | ->setFinder(PhpCsFixer\Finder::create()->exclude('vendor')->in(__DIR__)) 29 | ; 30 | -------------------------------------------------------------------------------- /API_ENDPOINTS.md: -------------------------------------------------------------------------------- 1 | # nfsen-ng API endpoints 2 | 3 | ### /api/config 4 | * **URL** 5 | `/api/config` 6 | 7 | * **Method:** 8 | `GET` 9 | 10 | * **URL Params** 11 | none 12 | 13 | * **Success Response:** 14 | 15 | * **Code:** 200 16 | **Content:** 17 | ```json 18 | { 19 | "sources": [ "gate", "swi6" ], 20 | "ports": [ 80, 22, 23 ], 21 | "stored_output_formats": [], 22 | "stored_filters": [], 23 | "daemon_running": true 24 | } 25 | ``` 26 | 27 | * **Error Response:** 28 | 29 | * **Code:** 400 BAD REQUEST 30 | **Content:** 31 | ```json 32 | {"code": 400, "error": "400 - Bad Request. Probably wrong or not enough arguments."} 33 | ``` 34 | OR 35 | 36 | * **Code:** 404 NOT FOUND 37 | **Content:** 38 | ```json 39 | {"code": 404, "error": "400 - Not found. "} 40 | ``` 41 | 42 | * **Sample Call:** 43 | ```sh 44 | curl localhost/nfsen-ng/api/config 45 | ``` 46 | 47 | ### /api/graph 48 | * **URL** 49 | `/api/graph?datestart=1490484000&dateend=1490652000&type=flows&sources[0]=gate&protocols[0]=tcp&protocols[1]=icmp&display=sources` 50 | 51 | * **Method:** 52 | 53 | `GET` 54 | 55 | * **URL Params** 56 | * `datestart=[integer]` Unix timestamp 57 | * `dateend=[integer]` Unix timestamp 58 | * `type=[string]` Type of data to show: flows/packets/traffic 59 | * `sources=[array]` 60 | * `protocols=[array]` 61 | * `ports=[array]` 62 | * `display=[string]` can be `sources`, `protocols` or `ports` 63 | 64 | There can't be multiple sources and multiple protocols both. Either one source and multiple protocols, or one protocol and multiple sources. 65 | 66 | * **Success Response:** 67 | 68 | * **Code:** 200 69 | **Content:** 70 | ```json 71 | {"data": { 72 | "1490562300":[2.1666666667,94.396666667], 73 | "1490562600":[1.0466666667,72.976666667],... 74 | },"start":1490562300,"end":1490590800,"step":300,"legend":["swi6_flows_tcp","gate_flows_tcp"]} 75 | ``` 76 | 77 | * **Error Response:** 78 | * **Code:** 400 BAD REQUEST
79 | **Content:** 80 | ```json 81 | {"code": 400, "error": "400 - Bad Request. Probably wrong or not enough arguments."} 82 | ``` 83 | 84 | OR 85 | 86 | * **Code:** 404 NOT FOUND
87 | **Content:** 88 | ```json 89 | {"code": 404, "error": "400 - Not found. "} 90 | ``` 91 | 92 | * **Sample Call:** 93 | 94 | ```sh 95 | curl -g "http://localhost/nfsen-ng/api/graph?datestart=1490484000&dateend=1490652000&type=flows&sources[0]=gate&protocols[0]=tcp&protocols[1]=icmp&display=sources" 96 | ``` 97 | 98 | ### /api/flows 99 | * **URL** 100 | `/api/flows?datestart=1482828600&dateend=1490604300&sources[0]=gate&sources[1]=swi6&filter=&limit=100&aggregate=srcip&sort=&output[format]=auto` 101 | 102 | * **Method:** 103 | 104 | `GET` 105 | 106 | * **URL Params** 107 | * `datestart=[integer]` Unix timestamp 108 | * `dateend=[integer]` Unix timestamp 109 | * `sources=[array]` 110 | * `filter=[string]` pcap-syntaxed filter 111 | * `limit=[int]` max. returned rows 112 | * `aggregate=[string]` can be `bidirectional` or a valid nfdump aggregation string (e.g. `srcip4/24, dstport`), but not both at the same time 113 | * `sort=[string]` (will probably cease to exist, as ordering is done directly in aggregation) e.g. `tstart` 114 | * `output=[array]` can contain `[format] = auto|line|long|extended` and `[IPv6]` 115 | 116 | * **Success Response:** 117 | 118 | * **Code:** 200 119 | **Content:** 120 | ```json 121 | [["ts","td","sa","da","sp","dp","pr","ipkt","ibyt","opkt","obyt"], 122 | ["2017-03-27 10:40:46","0.000","85.105.45.96","0.0.0.0","0","0","","1","46","0","0"], 123 | ... 124 | ``` 125 | 126 | * **Error Response:** 127 | 128 | * **Code:** 400 BAD REQUEST
129 | **Content:** 130 | ```json 131 | {"code": 400, "error": "400 - Bad Request. Probably wrong or not enough arguments."} 132 | ``` 133 | 134 | OR 135 | 136 | * **Code:** 404 NOT FOUND
137 | **Content:** 138 | ```json 139 | {"code": 404, "error": "400 - Not found. "} 140 | ``` 141 | 142 | * **Sample Call:** 143 | 144 | ```sh 145 | curl -g "http://localhost/nfsen-ng/api/flows?datestart=1482828600&dateend=1490604300&sources[0]=gate&sources[1]=swi6&filter=&limit=100&aggregate[]=srcip&sort=&output[format]=auto" 146 | ``` 147 | 148 | ### /api/stats 149 | * **URL** 150 | `/api/stats?datestart=1482828600&dateend=1490604300&sources[0]=gate&sources[1]=swi6&for=dstip&filter=&top=10&limit=100&aggregate[]=srcip&sort=&output[format]=auto` 151 | 152 | * **Method:** 153 | 154 | `GET` 155 | 156 | * **URL Params** 157 | * `datestart=[integer]` Unix timestamp 158 | * `dateend=[integer]` Unix timestamp 159 | * `sources=[array]` 160 | * `filter=[string]` pcap-syntaxed filter 161 | * `top=[int]` return top N rows 162 | * `for=[string]` field to get the statistics for. with optional ordering field as suffix, e.g. `ip/flows` 163 | * `limit=[string]` limit output to records above or below of `limit` e.g. `500K` 164 | * `output=[array]` can contain `[IPv6]` 165 | 166 | * **Success Response:** 167 | 168 | * **Code:** 200 169 | **Content:** 170 | ```json 171 | [ 172 | ["Packet limit: > 100 packets"], 173 | ["ts","te","td","pr","val","fl","flP","ipkt","ipktP","ibyt","ibytP","ipps","ipbs","ibpp"], 174 | ["2017-03-27 10:38:20","2017-03-27 10:47:58","577.973","any","193.5.80.180","673","2.7","676","2.5","56581","2.7","1","783","83"], 175 | ... 176 | ] 177 | ``` 178 | 179 | * **Error Response:** 180 | 181 | * **Code:** 400 BAD REQUEST
182 | **Content:** 183 | ```json 184 | {"code": 400, "error": "400 - Bad Request. Probably wrong or not enough arguments."} 185 | ``` 186 | 187 | OR 188 | 189 | * **Code:** 404 NOT FOUND
190 | **Content:** 191 | ```json 192 | {"code": 404, "error": "400 - Not found. "} 193 | ``` 194 | 195 | * **Sample Call:** 196 | 197 | ```sh 198 | curl -g "http://localhost/nfsen-ng/api/stats?datestart=1482828600&dateend=1490604300&sources[0]=gate&sources[1]=swi6&for=dstip&filter=&top=10&limit=100&aggregate[]=srcip&sort=&output[format]=auto" 199 | ``` 200 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # nfsen-ng installation 2 | 3 | These instructions install nfsen-ng on a fresh Ubuntu 20.04/22.04 LTS or Debian 11/12 system. 4 | 5 | **Note that setup of nfcapd is not covered here, but nfsen-ng requires data captured by nfcapd to work.** 6 | 7 | ## Ubuntu 20.04/22.04 LTS 8 | 9 | ```bash 10 | # run following commands as root 11 | 12 | # add php repository 13 | add-apt-repository -y ppa:ondrej/php 14 | 15 | # install packages 16 | apt install apache2 git pkg-config php8.3 php8.3-dev php8.3-mbstring libapache2-mod-php8.3 rrdtool librrd-dev 17 | 18 | # compile nfdump (optional, if you want to use the most recent version) 19 | apt install flex libbz2-dev yacc unzip 20 | wget https://github.com/phaag/nfdump/archive/refs/tags/v1.7.4.zip 21 | unzip v1.7.4.zip 22 | cd nfdump-1.7.4/ 23 | ./autogen.sh 24 | ./configure 25 | make 26 | make install 27 | ldconfig 28 | nfdump -V 29 | 30 | # enable apache modules 31 | a2enmod rewrite deflate headers expires 32 | 33 | # install rrd library for php 34 | pecl install rrd 35 | 36 | # create rrd library mod entry for php 37 | echo "extension=rrd.so" > /etc/php/8.3/mods-available/rrd.ini 38 | 39 | # enable php mods 40 | phpenmod rrd mbstring 41 | 42 | # configure virtual host to read .htaccess files 43 | vi /etc/apache2/apache2.conf # set AllowOverride All for /var/www directory 44 | 45 | # restart apache web server 46 | systemctl restart apache2 47 | 48 | # install nfsen-ng 49 | cd /var/www # or wherever, needs to be in the web root 50 | git clone https://github.com/mbolli/nfsen-ng 51 | chown -R www-data:www-data . 52 | chmod +x nfsen-ng/backend/cli.php 53 | 54 | cd nfsen-ng 55 | # install composer with instructions from https://getcomposer.org/download/ 56 | php composer.phar install --no-dev 57 | 58 | # next step: create configuration file from backend/settings/settings.php.dist 59 | ``` 60 | 61 | ## Debian 11/12 62 | 63 | ```bash 64 | # run following commands as root 65 | 66 | # add php repository 67 | apt install -y apt-transport-https lsb-release ca-certificates wget 68 | echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" | tee /etc/apt/sources.list.d/php.list 69 | apt update 70 | 71 | # install packages 72 | apt install apache2 git pkg-config php8.3 php8.3-dev php8.3-mbstring libapache2-mod-php8.3 rrdtool librrd-dev 73 | 74 | # compile nfdump (optional, if you want to use the most recent version) 75 | apt install flex libbz2-dev yacc unzip 76 | wget https://github.com/phaag/nfdump/archive/refs/tags/v1.7.4.zip 77 | unzip v1.7.4.zip 78 | cd nfdump-1.7.4/ 79 | ./autogen.sh 80 | ./configure 81 | make 82 | make install 83 | ldconfig 84 | nfdump -V 85 | 86 | # enable apache modules 87 | a2enmod rewrite deflate headers expires 88 | 89 | # install rrd library for php 90 | pecl install rrd 91 | 92 | # create rrd library mod entry for php 93 | echo "extension=rrd.so" > /etc/php/8.3/mods-available/rrd.ini 94 | 95 | # enable php mods 96 | phpenmod rrd mbstring 97 | 98 | # configure virtual host to read .htaccess files 99 | vi /etc/apache2/apache2.conf # set AllowOverride All for /var/www 100 | 101 | # restart apache web server 102 | systemctl restart apache2 103 | 104 | # install nfsen-ng 105 | cd /var/www # or wherever 106 | git clone https://github.com/mbolli/nfsen-ng 107 | chown -R www-data:www-data . 108 | chmod +x nfsen-ng/backend/cli.php 109 | cd nfsen-ng 110 | 111 | # install composer with instructions from https://getcomposer.org/download/ 112 | php composer.phar install --no-dev 113 | 114 | # next step: create configuration file from backend/settings/settings.php.dist 115 | ``` 116 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nfsen-ng 2 | 3 | [![GitHub license](https://img.shields.io/github/license/mbolli/nfsen-ng.svg?style=flat-square)](https://github.com/mbolli/nfsen-ng/blob/master/LICENSE) 4 | [![GitHub issues](https://img.shields.io/github/issues/mbolli/nfsen-ng.svg?style=flat-square)](https://github.com/mbolli/nfsen-ng/issues) 5 | [![Donate a beer](https://img.shields.io/badge/paypal-donate-yellow.svg?style=flat-square)](https://paypal.me/bolli) 6 | 7 | nfsen-ng is an in-place replacement for the ageing nfsen. 8 | 9 | ![nfsen-ng dashboard overview](https://github.com/mbolli/nfsen-ng/assets/722725/c3df942e-3d3c-4ef9-86ad-4e5780c7b6d8) 10 | 11 | ## Used components 12 | 13 | * Front end: [jQuery](https://jquery.com), [dygraphs](http://dygraphs.com), [FooTable](http://fooplugins.github.io/FooTable/), [ion.rangeSlider](http://ionden.com/a/plugins/ion.rangeSlider/en.html) 14 | * Back end: [RRDtool](http://oss.oetiker.ch/rrdtool/), [nfdump tools](https://github.com/phaag/nfdump) 15 | 16 | ## TOC 17 | 18 | * [nfsen-ng](#nfsen-ng) 19 | * [Installation](#installation) 20 | * [Configuration](#configuration) 21 | * [Nfdump](#nfdump) 22 | * [CLI/Daemon](#cli--daemon) 23 | * [Daemon as a systemd service](#daemon-as-a-systemd-service) 24 | * [Logs](#logs) 25 | * [API](#api) 26 | 27 | ## Installation 28 | 29 | Detailed installation instructions are available in [INSTALL.md](./INSTALL.md). Pull requests for additional distributions are welcome. 30 | 31 | Software packages required: 32 | 33 | * nfdump 34 | * rrdtool 35 | * git 36 | * composer 37 | * apache2 38 | * php >= 8.1 39 | 40 | Apache modules required: 41 | 42 | * mod_rewrite 43 | * mod_deflate 44 | * mod_headers 45 | * mod_expires 46 | 47 | PHP modules required: 48 | 49 | * mbstring 50 | * rrd 51 | 52 | ## Configuration 53 | 54 | > *Note:* nfsen-ng expects the `profiles_data` folder structure to be `PROFILES_DATA_PATH/PROFILE/SOURCE/YYYY/MM/DD/nfcapd.YYYYMMDDHHII`, e.g. `/var/nfdump/profiles_data/live/source1/2018/12/01/nfcapd.201812010225`. 55 | 56 | The default settings file is `backend/settings/settings.php.dist`. Copy it to `backend/settings/settings.php` and start modifying it. Example values are in *italic*: 57 | 58 | * **general** 59 | * **ports:** (*array(80, 23, 22, ...)*) The ports to examine. *Note:* If you use RRD as datasource and want to import existing data, you might keep the number of ports to a minimum, or the import time will be measured in moon cycles... 60 | * **sources:** (*array('source1', ...)*) The sources to scan. 61 | * **db:** (*RRD*) The name of the datasource class (case-sensitive). 62 | * **frontend** 63 | * **reload_interval:** Interval in seconds between graph reloads. 64 | * **nfdump** 65 | * **binary:** (*/usr/bin/nfdump*) The location of your nfdump executable 66 | * **profiles-data:** (*/var/nfdump/profiles_data*) The location of your nfcapd files 67 | * **profile:** (*live*) The profile folder to use 68 | * **max-processes:** (*1*) The maximum number of concurrently running nfdump processes. *Note:* Statistics and aggregations can use lots of system resources, even to aggregate one week of data might take more than 15 minutes. Put this value to > 1 if you want nfsen-ng to be usable while running another query. 69 | * **db** If the used data source needs additional configuration, you can specify it here, e.g. host and port. 70 | * **log** 71 | * **priority:** (*LOG_INFO*) see other possible values at [http://php.net/manual/en/function.syslog.php] 72 | 73 | ### Nfdump 74 | 75 | Nfsen-ng uses nfdump to read the nfcapd files. You can specify the location of the nfdump binary in `backend/settings/settings.php`. The default location is `/usr/bin/nfdump`. 76 | 77 | You should also have a look at the nfdump configuration file `/etc/nfdump.conf` and make sure that the `nfcapd` files are written to the correct location. The default location is `/var/nfdump/profiles_data`. 78 | 79 | Hhere is an example of an nfdump configuration: 80 | 81 | ```ini 82 | options='-z -S 1 -T all -l /var/nfdump/profiles_data/live/ -p ' 83 | ``` 84 | 85 | where 86 | 87 | * `-z` is used to compress the nfcapd files 88 | * `-S 1` is used to specify the nfcapd directory structure 89 | * `-T all` is used to specify the extension of the nfcapd files 90 | * `-l` is used to specify the destination location of the nfcapd files 91 | * `-p` is used to specify the port of the nfcapd files. 92 | 93 | #### Nfcapd x Sfcapd 94 | 95 | To use sfcapd instead of nfcapd, you have to change the `nfdump` configuration file `/lib/systemd/system/nfdump@.service` to use `sfcapd` instead of `nfcapd`: 96 | 97 | ```ini 98 | [Unit] 99 | Description=netflow capture daemon, %I instance 100 | Documentation=man:sfcapd(1) 101 | After=network.target auditd.service 102 | PartOf=nfdump.service 103 | 104 | [Service] 105 | Type=forking 106 | EnvironmentFile=/etc/nfdump/%I.conf 107 | ExecStart=/usr/bin/sfcapd -D -P /run/sfcapd.%I.pid $options 108 | PIDFile=/run/sfcapd.%I.pid 109 | KillMode=process 110 | Restart=no 111 | 112 | [Install] 113 | WantedBy=multi-user.target 114 | ``` 115 | 116 | ## CLI + Daemon 117 | 118 | The command line interface is used to initially scan existing nfcapd.* files, or to administer the daemon. 119 | 120 | Usage: 121 | 122 | `./cli.php [ options ] import` 123 | 124 | or for the daemon 125 | 126 | `./cli.php start|stop|status` 127 | 128 | * **Options:** 129 | * **-v** Show verbose output 130 | * **-p** Import ports data as well *Note:* Using RRD this will take quite a bit longer, depending on the number of your defined ports. 131 | * **-ps** Import ports per source as well *Note:* Using RRD this will take quite a bit longer, depending on the number of your defined ports. 132 | * **-f** Force overwriting database and start fresh 133 | 134 | * **Commands:** 135 | * **import** Import existing nfdump data to nfsen-ng. *Note:* If you have existing nfcapd files, better do this overnight or over a week-end. 136 | * **start** Start the daemon for continuous reading of new data 137 | * **stop** Stop the daemon 138 | * **status** Get the daemon's status 139 | 140 | * **Examples:** 141 | * `./cli.php -f import` 142 | Imports fresh data for sources 143 | 144 | * `./cli.php -f -p -ps import` 145 | Imports all data 146 | 147 | * `./cli.php start` 148 | Starts the daemon 149 | 150 | ### Daemon as a systemd service 151 | 152 | You can use the daemon as a service. To do so, you can use the provided systemd service file below. You can copy it to `/etc/systemd/system/nfsen-ng.service` and then start it with `systemctl start nfsen-ng`. 153 | 154 | ```ini 155 | [Unit] 156 | Description=nfsen-ng 157 | After=network-online.target 158 | 159 | [Service] 160 | Type=simple 161 | RemainAfterExit=yes 162 | restart=always 163 | startLimitIntervalSec=0 164 | restartSec=2 165 | ExecStart=su - www-data --shell=/bin/bash -c '/var/www/html/nfsen-ng/backend/cli.php start' 166 | ExecStop=su - www-data --shell=/bin/bash -c '/var/www/html/nfsen-ng/backend/cli.php stop' 167 | 168 | [Install] 169 | WantedBy=multi-user.target 170 | ``` 171 | 172 | Now, you should reload and enable the service to start on boot with `systemctl daemon-reload` and `systemctl enable nfsen-ng`. 173 | 174 | ## Logs 175 | 176 | Nfsen-ng logs to syslog. You can find the logs in `/var/log/syslog` or `/var/log/messages` depending on your system. Some distributions might register it in `journalctl`. To access the logs, you can use `tail -f /var/log/syslog` or `journalctl -u nfsen-ng` 177 | 178 | You can change the log priority in `backend/settings/settings.php`. 179 | 180 | ## API 181 | 182 | The API is used by the frontend to retrieve data. The API endpoints are documented in [API_ENDPOINTS.md](./API_ENDPOINTS.md). 183 | -------------------------------------------------------------------------------- /backend/api/Api.php: -------------------------------------------------------------------------------- 1 | error(503, $e->getMessage()); 22 | } 23 | 24 | // get the HTTP method, path and body of the request 25 | $this->method = $_SERVER['REQUEST_METHOD']; 26 | $this->request = explode('/', trim((string) $_GET['request'], '/')); 27 | 28 | // only allow GET requests 29 | // if at some time POST requests are enabled, check the request's content type (or return 406) 30 | if ($this->method !== 'GET') { 31 | $this->error(501); 32 | } 33 | 34 | // call correct method 35 | if (!method_exists($this, $this->request[0])) { 36 | $this->error(404); 37 | } 38 | 39 | // remove method name from $_REQUEST 40 | $_REQUEST = array_filter($_REQUEST, fn ($x) => $x !== $this->request[0]); 41 | 42 | $method = new \ReflectionMethod($this, $this->request[0]); 43 | 44 | // check number of parameters 45 | if ($method->getNumberOfRequiredParameters() > \count($_REQUEST)) { 46 | $this->error(400, 'Not enough parameters'); 47 | } 48 | 49 | $args = []; 50 | // iterate over each parameter 51 | foreach ($method->getParameters() as $arg) { 52 | if (!isset($_REQUEST[$arg->name])) { 53 | if ($arg->isOptional()) { 54 | continue; 55 | } 56 | $this->error(400, 'Expected parameter ' . $arg->name); 57 | } 58 | 59 | /** @var ?\ReflectionNamedType $namedType */ 60 | $namedType = $arg->getType(); 61 | if ($namedType === null) { 62 | continue; 63 | } 64 | 65 | // make sure the data types are correct 66 | switch ($namedType->getName()) { 67 | case 'int': 68 | if (!is_numeric($_REQUEST[$arg->name])) { 69 | $this->error(400, 'Expected type int for ' . $arg->name); 70 | } 71 | $args[$arg->name] = (int) $_REQUEST[$arg->name]; 72 | break; 73 | case 'array': 74 | if (!\is_array($_REQUEST[$arg->name])) { 75 | $this->error(400, 'Expected type array for ' . $arg->name); 76 | } 77 | $args[$arg->name] = $_REQUEST[$arg->name]; 78 | break; 79 | case 'string': 80 | if (!\is_string($_REQUEST[$arg->name])) { 81 | $this->error(400, 'Expected type string for ' . $arg->name); 82 | } 83 | $args[$arg->name] = $_REQUEST[$arg->name]; 84 | break; 85 | default: 86 | $args[$arg->name] = $_REQUEST[$arg->name]; 87 | } 88 | } 89 | 90 | // get output 91 | $output = $this->{$this->request[0]}(...array_values($args)); 92 | 93 | // return output 94 | if (\array_key_exists('csv', $_REQUEST)) { 95 | // output CSV 96 | header('Content-Type: text/csv; charset=utf-8'); 97 | header('Content-Disposition: attachment; filename=export.csv'); 98 | $return = fopen('php://output', 'w'); 99 | foreach ($output as $i => $line) { 100 | if ($i === 0) { 101 | continue; 102 | } // skip first line 103 | fputcsv($return, $line); 104 | } 105 | 106 | fclose($return); 107 | } else { 108 | // output JSON 109 | echo json_encode($output, \JSON_THROW_ON_ERROR); 110 | } 111 | } 112 | 113 | /** 114 | * Helper function, returns the http status and exits the application. 115 | * 116 | * @throws \JsonException 117 | */ 118 | public function error(int $code, string $msg = ''): never { 119 | http_response_code($code); 120 | $debug = Debug::getInstance(); 121 | 122 | $response = ['code' => $code, 'error' => '']; 123 | switch ($code) { 124 | case 400: 125 | $response['error'] = '400 - Bad Request. ' . (empty($msg) ? 'Probably wrong or not enough arguments.' : $msg); 126 | $debug->log($response['error'], \LOG_INFO); 127 | break; 128 | case 401: 129 | $response['error'] = '401 - Unauthorized. ' . $msg; 130 | $debug->log($response['error'], \LOG_WARNING); 131 | break; 132 | case 403: 133 | $response['error'] = '403 - Forbidden. ' . $msg; 134 | $debug->log($response['error'], \LOG_WARNING); 135 | break; 136 | case 404: 137 | $response['error'] = '404 - Not found. ' . $msg; 138 | $debug->log($response['error'], \LOG_WARNING); 139 | break; 140 | case 501: 141 | $response['error'] = '501 - Method not implemented. ' . $msg; 142 | $debug->log($response['error'], \LOG_WARNING); 143 | break; 144 | case 503: 145 | $response['error'] = '503 - Service unavailable. ' . $msg; 146 | $debug->log($response['error'], \LOG_ERR); 147 | break; 148 | } 149 | echo json_encode($response, \JSON_THROW_ON_ERROR); 150 | exit; 151 | } 152 | 153 | /** 154 | * Execute the processor to get statistics. 155 | * 156 | * @return array 157 | */ 158 | public function stats( 159 | int $datestart, 160 | int $dateend, 161 | array $sources, 162 | string $filter, 163 | int $top, 164 | string $for, 165 | string $limit, 166 | array $output = [], 167 | ) { 168 | $sources = implode(':', $sources); 169 | 170 | $processor = Config::$processorClass; 171 | $processor->setOption('-M', $sources); // multiple sources 172 | $processor->setOption('-R', [$datestart, $dateend]); // date range 173 | $processor->setOption('-n', $top); 174 | if (\array_key_exists('format', $output)) { 175 | $processor->setOption('-o', $output['format']); 176 | 177 | if ($output['format'] === 'custom' && \array_key_exists('custom', $output) && !empty($output['custom'])) { 178 | $processor->setOption('-o', 'fmt:' . $output['custom']); 179 | } 180 | } 181 | 182 | $processor->setOption('-s', $for); 183 | if (!empty($limit)) { 184 | $processor->setOption('-l', $limit); 185 | } // todo -L for traffic, -l for packets 186 | 187 | $processor->setFilter($filter); 188 | 189 | try { 190 | return $processor->execute(); 191 | } catch (\Exception $e) { 192 | $this->error(503, $e->getMessage()); 193 | } 194 | } 195 | 196 | /** 197 | * Execute the processor to get flows. 198 | * 199 | * @return array 200 | */ 201 | public function flows( 202 | int $datestart, 203 | int $dateend, 204 | array $sources, 205 | string $filter, 206 | int $limit, 207 | string $aggregate, 208 | string $sort, 209 | array $output, 210 | ) { 211 | $aggregate_command = ''; 212 | // nfdump -M /srv/nfsen/profiles-data/live/tiber:n048:gate:swibi:n055:swi6 -T -r 2017/04/10/nfcapd.201704101150 -c 20 213 | $sources = implode(':', $sources); 214 | if (!empty($aggregate)) { 215 | $aggregate_command = ($aggregate === 'bidirectional') ? '-B' : '-A' . $aggregate; 216 | } // no space inbetween 217 | 218 | $processor = new Config::$processorClass(); 219 | $processor->setOption('-M', $sources); // multiple sources 220 | $processor->setOption('-R', [$datestart, $dateend]); // date range 221 | $processor->setOption('-c', $limit); // limit 222 | $processor->setOption('-o', $output['format']); 223 | if ($output['format'] === 'custom' && \array_key_exists('custom', $output) && !empty($output['custom'])) { 224 | $processor->setOption('-o', 'fmt:' . $output['custom']); 225 | } 226 | 227 | if (!empty($sort)) { 228 | $processor->setOption('-O', 'tstart'); 229 | } 230 | if (!empty($aggregate_command)) { 231 | $processor->setOption('-a', $aggregate_command); 232 | } 233 | $processor->setFilter($filter); 234 | 235 | try { 236 | $return = $processor->execute(); 237 | } catch (\Exception $e) { 238 | $this->error(503, $e->getMessage()); 239 | } 240 | 241 | return $return; 242 | } 243 | 244 | /** 245 | * Get data to build a graph. 246 | * 247 | * @return array|string 248 | */ 249 | public function graph( 250 | int $datestart, 251 | int $dateend, 252 | array $sources, 253 | array $protocols, 254 | array $ports, 255 | string $type, 256 | string $display, 257 | ) { 258 | $graph = Config::$db->get_graph_data($datestart, $dateend, $sources, $protocols, $ports, $type, $display); 259 | if (!\is_array($graph)) { 260 | $this->error(400, $graph); 261 | } 262 | 263 | return $graph; 264 | } 265 | 266 | public function graph_stats(): void {} 267 | 268 | /** 269 | * Get config info. 270 | * 271 | * @return array 272 | */ 273 | public function config() { 274 | $sources = Config::$cfg['general']['sources']; 275 | $ports = Config::$cfg['general']['ports']; 276 | $frontend = Config::$cfg['frontend']; 277 | 278 | $stored_output_formats = Config::$cfg['general']['formats']; 279 | $stored_filters = Config::$cfg['general']['filters']; 280 | 281 | $folder = \dirname(__FILE__, 2); 282 | $pidfile = $folder . '/nfsen-ng.pid'; 283 | $daemon_running = file_exists($pidfile); 284 | 285 | // get date of first data point 286 | $firstDataPoint = \PHP_INT_MAX; 287 | foreach ($sources as $source) { 288 | $firstDataPoint = min($firstDataPoint, Config::$db->date_boundaries($source)[0]); 289 | } 290 | $frontend['data_start'] = $firstDataPoint; 291 | 292 | return [ 293 | 'sources' => $sources, 294 | 'ports' => $ports, 295 | 'stored_output_formats' => $stored_output_formats, 296 | 'stored_filters' => $stored_filters, 297 | 'daemon_running' => $daemon_running, 298 | 'frontend' => $frontend, 299 | 'version' => Config::VERSION, 300 | 'tz_offset' => (new \DateTimeZone(date_default_timezone_get()))->getOffset(new \DateTime('now', new \DateTimeZone('UTC'))) / 3600, 301 | ]; 302 | } 303 | 304 | /** 305 | * executes the host command with a timeout of 5 seconds. 306 | */ 307 | public function host(string $ip): string { 308 | try { 309 | // check ip format 310 | if (!filter_var($ip, \FILTER_VALIDATE_IP)) { 311 | $this->error(400, 'Invalid IP address'); 312 | } 313 | 314 | exec('host -W 5 ' . $ip, $output, $return_var); 315 | if ($return_var !== 0) { 316 | $this->error(404, 'Host command failed'); 317 | } 318 | 319 | $output = implode(' ', $output); 320 | if (!preg_match('/domain name pointer (.*)\./', $output, $matches)) { 321 | $this->error(500, "Could not parse host output: {$output}"); 322 | } 323 | 324 | return $matches[1]; 325 | } catch (\Exception $e) { 326 | $this->error(500, $e->getMessage()); 327 | } 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /backend/cli.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | log('Fatal: ' . $e->getMessage(), \LOG_ALERT); 14 | exit; 15 | } 16 | 17 | if ($argc < 2 || in_array($argv[1], ['--help', '-help', '-h', '-?'], true)) { 18 | ?> 19 | 20 | This is the command line interface to nfsen-ng. 21 | 22 | Usage: 23 | [options] import 24 | start|stop|status 25 | 26 | Options: 27 | -v Show verbose output 28 | -p Import ports data 29 | -ps Import ports data per source 30 | -f Force overwriting database and start at the beginning 31 | 32 | Commands: 33 | import - Import existing nfdump data to nfsen-ng. 34 | Notice: If you have existing nfcapd files, better do this overnight. 35 | start - Start the daemon for continuous reading of new data 36 | stop - Stop the daemon 37 | status - Get the daemon's status 38 | 39 | Examples: 40 | -f import 41 | Imports fresh data for sources 42 | 43 | -s -p import 44 | Imports data for ports only 45 | 46 | start 47 | Start the daemon 48 | 49 | log('CLI: Starting import', \LOG_INFO); 58 | $start = new DateTime(); 59 | $start->setDate(date('Y') - 3, (int) date('m'), (int) date('d')); 60 | $i = new Import(); 61 | if (in_array('-v', $argv, true)) { 62 | $i->setVerbose(true); 63 | } 64 | if (in_array('-p', $argv, true)) { 65 | $i->setProcessPorts(true); 66 | } 67 | if (in_array('-ps', $argv, true)) { 68 | $i->setProcessPortsBySource(true); 69 | } 70 | if (in_array('-f', $argv, true)) { 71 | $i->setForce(true); 72 | } 73 | $i->start($start); 74 | } elseif (in_array('start', $argv, true)) { 75 | // start the daemon 76 | 77 | $d->log('CLI: Starting daemon...', \LOG_INFO); 78 | $pid = exec('nohup `which php` ' . $folder . '/listen.php > /dev/null 2>&1 & echo $!', $op, $exit); 79 | var_dump($exit); 80 | // todo: get exit code of background process. possible at all? 81 | switch ((int) $exit) { 82 | case 128: 83 | echo 'Unexpected error opening or locking lock file. Perhaps you don\'t have permission to write to the lock file or its containing directory?'; 84 | break; 85 | case 129: 86 | echo 'Another instance is already running; terminating.'; 87 | break; 88 | default: 89 | echo 'Daemon running, pid=' . $pid; 90 | break; 91 | } 92 | echo \PHP_EOL; 93 | } elseif (in_array('stop', $argv, true)) { 94 | // stop the daemon 95 | 96 | if (!file_exists($pidfile)) { 97 | echo 'Not running' . \PHP_EOL; 98 | exit; 99 | } 100 | $pid = file_get_contents($pidfile); 101 | $d->log('CLI: Stopping daemon', \LOG_INFO); 102 | exec('kill ' . $pid); 103 | unlink($pidfile); 104 | 105 | echo 'Stopped.' . \PHP_EOL; 106 | } elseif (in_array('status', $argv, true)) { 107 | // print the daemon status 108 | 109 | if (!file_exists($pidfile)) { 110 | echo 'Not running' . \PHP_EOL; 111 | exit; 112 | } 113 | $pid = file_get_contents($pidfile); 114 | exec('ps -p ' . $pid, $op); 115 | if (!isset($op[1])) { 116 | echo 'Not running' . \PHP_EOL; 117 | } else { 118 | echo 'Running: ' . $pid . \PHP_EOL; 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /backend/common/Config.php: -------------------------------------------------------------------------------- 1 | }, 14 | * nfdump: array{binary: string, profiles-data: string, profile: string, max-processes: int}, 15 | * db: array, 16 | * log: array{priority: int} 17 | * }|array{} 18 | */ 19 | public static array $cfg = []; 20 | public static string $path; 21 | public static Datasource $db; 22 | public static Processor $processorClass; 23 | private static bool $initialized = false; 24 | 25 | private function __construct() {} 26 | 27 | public static function initialize(bool $initProcessor = false): void { 28 | global $nfsen_config; 29 | if (self::$initialized === true) { 30 | return; 31 | } 32 | 33 | $settingsFile = \dirname(__DIR__) . \DIRECTORY_SEPARATOR . 'settings' . \DIRECTORY_SEPARATOR . 'settings.php'; 34 | if (!file_exists($settingsFile)) { 35 | throw new \Exception('No settings.php found. Did you rename the distributed settings correctly?'); 36 | } 37 | 38 | include $settingsFile; 39 | 40 | self::$cfg = $nfsen_config; 41 | self::$path = \dirname(__DIR__); 42 | self::$initialized = true; 43 | 44 | // find data source 45 | $dbClass = 'mbolli\\nfsen_ng\\datasources\\' . ucfirst(mb_strtolower(self::$cfg['general']['db'])); 46 | if (class_exists($dbClass)) { 47 | self::$db = new $dbClass(); 48 | } else { 49 | throw new \Exception('Failed loading class ' . self::$cfg['general']['db'] . '. The class doesn\'t exist.'); 50 | } 51 | 52 | // find processor 53 | $processorClass = \array_key_exists('processor', self::$cfg['general']) ? ucfirst(mb_strtolower(self::$cfg['general']['processor'])) : 'Nfdump'; 54 | $processorClass = 'mbolli\\nfsen_ng\\processor\\' . $processorClass; 55 | if (!class_exists($processorClass)) { 56 | throw new \Exception('Failed loading class ' . $processorClass . '. The class doesn\'t exist.'); 57 | } 58 | 59 | if (!\in_array(Processor::class, class_implements($processorClass), true)) { 60 | throw new \Exception('Processor class ' . $processorClass . ' doesn\'t implement ' . Processor::class . '.'); 61 | } 62 | 63 | if ($initProcessor === true) { 64 | self::$processorClass = new $processorClass(); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /backend/common/Debug.php: -------------------------------------------------------------------------------- 1 | stopwatch = microtime(true); 13 | $this->cli = (\PHP_SAPI === 'cli'); 14 | } 15 | 16 | public static function getInstance(): self { 17 | if (!(self::$_instance instanceof self)) { 18 | self::$_instance = new self(); 19 | } 20 | 21 | return self::$_instance; 22 | } 23 | 24 | /** 25 | * Logs the message if allowed in settings. 26 | */ 27 | public function log(string $message, int $priority): void { 28 | if (Config::$cfg['log']['priority'] >= $priority) { 29 | syslog($priority, 'nfsen-ng: ' . $message); 30 | 31 | if ($this->cli === true && $this->debug === true) { 32 | echo date('Y-m-d H:i:s') . ' ' . $message . \PHP_EOL; 33 | } 34 | } 35 | } 36 | 37 | /** 38 | * Returns the time passed from initialization. 39 | */ 40 | public function stopWatch(bool $precise = false): float { 41 | $result = microtime(true) - $this->stopwatch; 42 | if ($precise === false) { 43 | $result = round($result, 4); 44 | } 45 | 46 | return $result; 47 | } 48 | 49 | /** 50 | * Debug print. Prints the supplied string with the time passed from initialization. 51 | */ 52 | public function dpr(...$mixed): void { 53 | if ($this->debug === false) { 54 | return; 55 | } 56 | 57 | foreach ($mixed as $param) { 58 | echo ($this->cli) ? \PHP_EOL . $this->stopWatch() . 's ' : "
" . $this->stopWatch() . ' '; 59 | if (\is_array($param)) { 60 | echo ($this->cli) ? print_r($mixed, true) : '
', var_export($mixed, true), '
'; 61 | } else { 62 | echo $param; 63 | } 64 | } 65 | } 66 | 67 | public function setDebug(bool $debug): void { 68 | $this->debug = $debug; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /backend/common/Import.php: -------------------------------------------------------------------------------- 1 | d = Debug::getInstance(); 19 | $this->cli = (\PHP_SAPI === 'cli'); 20 | $this->d->setDebug($this->verbose); 21 | } 22 | 23 | /** 24 | * @throws \Exception 25 | */ 26 | public function start(\DateTime $dateStart): void { 27 | $sources = Config::$cfg['general']['sources']; 28 | $processedSources = 0; 29 | 30 | // if in force mode, reset existing data 31 | if ($this->force === true) { 32 | if ($this->cli === true) { 33 | echo 'Resetting existing data...' . \PHP_EOL; 34 | } 35 | Config::$db->reset([]); 36 | } 37 | 38 | // start progress bar (CLI only) 39 | $daysTotal = ((int) $dateStart->diff(new \DateTime())->format('%a') + 1) * \count($sources); 40 | if ($this->cli === true && $this->quiet === false) { 41 | echo \PHP_EOL . \mbolli\nfsen_ng\vendor\ProgressBar::start($daysTotal, 'Processing ' . \count($sources) . ' sources...'); 42 | } 43 | 44 | // process each source, e.g. gateway, mailserver, etc. 45 | foreach ($sources as $nr => $source) { 46 | $sourcePath = Config::$cfg['nfdump']['profiles-data'] . \DIRECTORY_SEPARATOR . Config::$cfg['nfdump']['profile']; 47 | if (!file_exists($sourcePath)) { 48 | throw new \Exception('Could not read nfdump profile directory ' . $sourcePath); 49 | } 50 | if ($this->cli === true && $this->quiet === false) { 51 | echo \PHP_EOL . 'Processing source ' . $source . ' (' . ($nr + 1) . '/' . \count($sources) . ')...' . \PHP_EOL; 52 | } 53 | 54 | $date = clone $dateStart; 55 | 56 | // check if we want to continue a stopped import 57 | // assumes the last update of a source is similar to the last update of its ports... 58 | $lastUpdateDb = Config::$db->last_update($source); 59 | 60 | $lastUpdate = null; 61 | if ($lastUpdateDb !== false && $lastUpdateDb !== 0) { 62 | $lastUpdate = (new \DateTime())->setTimestamp($lastUpdateDb); 63 | } 64 | 65 | if ($this->force === false && isset($lastUpdate)) { 66 | $daysSaved = (int) $date->diff($lastUpdate)->format('%a'); 67 | $daysTotal -= $daysSaved; 68 | if ($this->quiet === false) { 69 | $this->d->log('Last update: ' . $lastUpdate->format('Y-m-d H:i'), \LOG_INFO); 70 | } 71 | if ($this->cli === true && $this->quiet === false) { 72 | \mbolli\nfsen_ng\vendor\ProgressBar::setTotal($daysTotal); 73 | } 74 | 75 | // set progress to the date when the import was stopped 76 | $date->setTimestamp($lastUpdateDb); 77 | $date->setTimezone(new \DateTimeZone(date_default_timezone_get())); 78 | } 79 | 80 | // iterate from $datestart until today 81 | while ((int) $date->format('Ymd') <= (int) (new \DateTime())->format('Ymd')) { 82 | $scan = [$sourcePath, $source, $date->format('Y'), $date->format('m'), $date->format('d')]; 83 | $scanPath = implode(\DIRECTORY_SEPARATOR, $scan); 84 | 85 | // set date to tomorrow for next iteration 86 | $date->modify('+1 day'); 87 | 88 | // if no data exists for current date (e.g. .../2017/03/03) 89 | if (!file_exists($scanPath)) { 90 | $this->d->dpr($scanPath . ' does not exist!'); 91 | if ($this->cli === true && $this->quiet === false) { 92 | echo \mbolli\nfsen_ng\vendor\ProgressBar::next(1); 93 | } 94 | continue; 95 | } 96 | 97 | // scan path 98 | $this->d->log('Scanning path ' . $scanPath, \LOG_INFO); 99 | $scanFiles = scandir($scanPath); 100 | 101 | if ($this->cli === true && $this->quiet === false) { 102 | echo \mbolli\nfsen_ng\vendor\ProgressBar::next(1, 'Scanning ' . $scanPath . '...'); 103 | } 104 | 105 | foreach ($scanFiles as $file) { 106 | if (\in_array($file, ['.', '..'], true)) { 107 | continue; 108 | } 109 | 110 | try { 111 | // parse date of file name to compare against last_update 112 | preg_match('/nfcapd\.([0-9]{12})$/', (string) $file, $fileDate); 113 | if (\count($fileDate) !== 2) { 114 | throw new \LengthException('Bad file name format of nfcapd file: ' . $file); 115 | } 116 | $fileDatetime = new \DateTime($fileDate[1]); 117 | } catch (\LengthException $e) { 118 | $this->d->log('Caught exception: ' . $e->getMessage(), \LOG_DEBUG); 119 | continue; 120 | } 121 | 122 | // compare file name date with last update 123 | if ($fileDatetime <= $lastUpdate) { 124 | continue; 125 | } 126 | 127 | // let nfdump parse each nfcapd file 128 | $statsPath = implode(\DIRECTORY_SEPARATOR, \array_slice($scan, 2, 5)) . \DIRECTORY_SEPARATOR . $file; 129 | 130 | try { 131 | // fill source.rrd 132 | $this->writeSourceData($source, $statsPath); 133 | 134 | // write general port data (queries data for all sources, should only be executed when data for all sources exists...) 135 | if ($this->processPorts === true && $nr === \count($sources) - 1) { 136 | $this->writePortsData($statsPath); 137 | } 138 | 139 | // if enabled, process ports per source as well (source_80.rrd) 140 | if ($this->processPortsBySource === true) { 141 | $this->writePortsData($statsPath, $source); 142 | } 143 | } catch (\Exception $e) { 144 | $this->d->log('Caught exception: ' . $e->getMessage(), \LOG_WARNING); 145 | } 146 | } 147 | } 148 | ++$processedSources; 149 | } 150 | if ($processedSources === 0) { 151 | $this->d->log('Import did not process any sources.', \LOG_WARNING); 152 | } 153 | if ($this->cli === true && $this->quiet === false) { 154 | echo \mbolli\nfsen_ng\vendor\ProgressBar::finish(); 155 | } 156 | } 157 | 158 | /** 159 | * @throws \Exception 160 | */ 161 | private function writeSourceData(string $source, string $statsPath): bool { 162 | // set options and get netflow summary statistics (-I) 163 | $nfdump = Nfdump::getInstance(); 164 | $nfdump->reset(); 165 | $nfdump->setOption('-I', null); 166 | $nfdump->setOption('-M', $source); 167 | $nfdump->setOption('-r', $statsPath); 168 | 169 | if ($this->dbUpdatable($statsPath, $source) === false) { 170 | return false; 171 | } 172 | 173 | try { 174 | $input = $nfdump->execute(); 175 | } catch (\Exception $e) { 176 | $this->d->log('Exception: ' . $e->getMessage(), \LOG_WARNING); 177 | 178 | return false; 179 | } 180 | 181 | $date = new \DateTime(mb_substr($statsPath, -12)); 182 | $data = [ 183 | 'fields' => [], 184 | 'source' => $source, 185 | 'port' => 0, 186 | 'date_iso' => $date->format('Ymd\THis'), 187 | 'date_timestamp' => $date->getTimestamp(), 188 | ]; 189 | // $input data is an array of lines looking like this: 190 | // flows_tcp: 323829 191 | foreach ($input as $i => $line) { 192 | if (!\is_string($line)) { 193 | $this->d->log('Got no output of previous command', \LOG_DEBUG); 194 | } 195 | if ($i === 0) { 196 | continue; 197 | } // skip nfdump command 198 | if (!preg_match('/:/', (string) $line)) { 199 | continue; 200 | } // skip invalid lines like error messages 201 | [$type, $value] = explode(': ', (string) $line); 202 | 203 | // we only need flows/packets/bytes values, the source and the timestamp 204 | if (preg_match('/^(flows|packets|bytes)/i', $type)) { 205 | $data['fields'][mb_strtolower($type)] = (int) $value; 206 | } 207 | } 208 | 209 | // write to database 210 | if (Config::$db->write($data) === false) { 211 | throw new \Exception('Error writing to ' . $statsPath); 212 | } 213 | 214 | return true; 215 | } 216 | 217 | /** 218 | * @throws \Exception 219 | */ 220 | private function writePortsData(string $statsPath, string $source = ''): bool { 221 | $ports = Config::$cfg['general']['ports']; 222 | 223 | foreach ($ports as $port) { 224 | $this->writePortData($port, $statsPath, $source); 225 | } 226 | 227 | return true; 228 | } 229 | 230 | /** 231 | * @throws \Exception 232 | */ 233 | private function writePortData(int $port, string $statsPath, string $source = ''): bool { 234 | $sources = Config::$cfg['general']['sources']; 235 | 236 | // set options and get netflow statistics 237 | $nfdump = Nfdump::getInstance(); 238 | $nfdump->reset(); 239 | 240 | if (empty($source)) { 241 | // if no source is specified, get data for all sources 242 | $nfdump->setOption('-M', implode(':', $sources)); 243 | if ($this->dbUpdatable($statsPath, '', $port) === false) { 244 | return false; 245 | } 246 | } else { 247 | $nfdump->setOption('-M', $source); 248 | if ($this->dbUpdatable($statsPath, $source, $port) === false) { 249 | return false; 250 | } 251 | } 252 | 253 | $nfdump->setFilter('dst port ' . $port); 254 | $nfdump->setOption('-s', 'dstport:p'); 255 | $nfdump->setOption('-r', $statsPath); 256 | 257 | try { 258 | $input = $nfdump->execute(); 259 | } catch (\Exception $e) { 260 | $this->d->log('Exception: ' . $e->getMessage(), \LOG_WARNING); 261 | 262 | return false; 263 | } 264 | 265 | // parse and turn into usable data 266 | 267 | $date = new \DateTime(mb_substr($statsPath, -12)); 268 | $data = [ 269 | 'fields' => [ 270 | 'flows' => 0, 271 | 'packets' => 0, 272 | 'bytes' => 0, 273 | ], 274 | 'source' => $source, 275 | 'port' => $port, 276 | 'date_iso' => $date->format('Ymd\THis'), 277 | 'date_timestamp' => $date->getTimestamp(), 278 | ]; 279 | 280 | // process protocols 281 | // headers: ts,te,td,pr,val,fl,flP,ipkt,ipktP,ibyt,ibytP,ipps,ipbs,ibpp 282 | foreach ($input as $i => $line) { 283 | if (!\is_array($line) && $line instanceof \Countable === false) { 284 | continue; 285 | } // skip anything uncountable 286 | if (\count($line) !== 14) { 287 | continue; 288 | } // skip anything invalid 289 | if ($line[0] === 'ts') { 290 | continue; 291 | } // skip header 292 | 293 | $proto = mb_strtolower((string) $line[3]); 294 | 295 | // add protocol-specific 296 | $data['fields']['flows_' . $proto] = (int) $line[5]; 297 | $data['fields']['packets_' . $proto] = (int) $line[7]; 298 | $data['fields']['bytes_' . $proto] = (int) $line[9]; 299 | 300 | // add to overall stats 301 | $data['fields']['flows'] += (int) $line[5]; 302 | $data['fields']['packets'] += (int) $line[7]; 303 | $data['fields']['bytes'] += (int) $line[9]; 304 | } 305 | 306 | // write to database 307 | if (Config::$db->write($data) === false) { 308 | throw new \Exception('Error writing to ' . $statsPath); 309 | } 310 | 311 | return true; 312 | } 313 | 314 | /** 315 | * Import a single nfcapd file. 316 | */ 317 | public function importFile(string $file, string $source, bool $last): void { 318 | try { 319 | $this->d->log('Importing file ' . $file . ' (' . $source . '), last=' . (int) $last, \LOG_INFO); 320 | 321 | // fill source.rrd 322 | $this->writeSourceData($source, $file); 323 | 324 | // write general port data (not depending on source, so only executed per port) 325 | if ($last === true) { 326 | $this->writePortsData($file); 327 | } 328 | 329 | // if enabled, process ports per source as well (source_80.rrd) 330 | if ($this->processPorts === true) { 331 | $this->writePortsData($file, $source); 332 | } 333 | } catch (\Exception $e) { 334 | $this->d->log('Caught exception: ' . $e->getMessage(), \LOG_WARNING); 335 | } 336 | } 337 | 338 | /** 339 | * Check if db is free to update (some databases only allow inserting data at the end). 340 | * 341 | * @throws \Exception 342 | */ 343 | public function dbUpdatable(string $file, string $source = '', int $port = 0): bool { 344 | if ($this->checkLastUpdate === false) { 345 | return true; 346 | } 347 | 348 | // parse capture file's datetime. can't use filemtime as we need the datetime in the file name. 349 | $date = []; 350 | if (!preg_match('/nfcapd\.([0-9]{12})$/', $file, $date)) { 351 | return false; 352 | } // nothing to import 353 | 354 | $fileDatetime = new \DateTime($date[1]); 355 | 356 | // get last updated time from database 357 | $lastUpdateDb = Config::$db->last_update($source, $port); 358 | $lastUpdate = null; 359 | if ($lastUpdateDb !== 0) { 360 | $lastUpdate = new \DateTime(); 361 | $lastUpdate->setTimestamp($lastUpdateDb); 362 | } 363 | 364 | // prevent attempt to import the same file again 365 | return $fileDatetime > $lastUpdate; 366 | } 367 | 368 | public function setVerbose(bool $verbose): void { 369 | if ($verbose === true) { 370 | $this->d->setDebug(true); 371 | } 372 | $this->verbose = $verbose; 373 | } 374 | 375 | public function setProcessPorts(bool $processPorts): void { 376 | $this->processPorts = $processPorts; 377 | } 378 | 379 | public function setForce(bool $force): void { 380 | $this->force = $force; 381 | } 382 | 383 | public function setQuiet(bool $quiet): void { 384 | $this->quiet = $quiet; 385 | } 386 | 387 | public function setProcessPortsBySource($processPortsBySource): void { 388 | $this->processPortsBySource = $processPortsBySource; 389 | } 390 | 391 | public function setCheckLastUpdate(bool $checkLastUpdate): void { 392 | $this->checkLastUpdate = $checkLastUpdate; 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /backend/datasources/Akumuli.php: -------------------------------------------------------------------------------- 1 | d = Debug::getInstance(); 14 | $this->connect(); 15 | } 16 | 17 | /** 18 | * connects to TCP socket. 19 | */ 20 | public function connect(): void { 21 | try { 22 | $this->client = stream_socket_client('tcp://' . Config::$cfg['db']['akumuli']['host'] . ':' . Config::$cfg['db']['akumuli']['port'], $errno, $errmsg); 23 | 24 | if ($this->client === false) { 25 | throw new \Exception('Failed to connect to Akumuli: ' . $errmsg); 26 | } 27 | } catch (\Exception $e) { 28 | $this->d->dpr($e); 29 | } 30 | } 31 | 32 | /** 33 | * Convert data to redis-compatible string and write to Akumuli. 34 | */ 35 | public function write(array $data): bool { 36 | $fields = array_keys($data['fields']); 37 | $values = array_values($data['fields']); 38 | 39 | // writes assume redis protocol. first byte identification: 40 | // "+" simple strings "-" errors ":" integers "$" bulk strings "*" array 41 | $query = '+' . implode('|', $fields) . ' source=' . $data['source'] . "\r\n" 42 | . '+' . $data['date_iso'] . "\r\n" // timestamp 43 | . '*' . \count($fields) . "\r\n"; // length of following array 44 | 45 | // add the $values corresponding to $fields 46 | foreach ($values as $v) { 47 | $query .= ':' . $v . "\r\n"; 48 | } 49 | 50 | $this->d->dpr([$query]); 51 | 52 | // write redis-compatible string to socket 53 | fwrite($this->client, $query); 54 | 55 | return stream_get_contents($this->client); 56 | 57 | // to read: 58 | // curl localhost:8181/api/query -d "{'select':'flows'}" 59 | } 60 | 61 | public function __destruct() { 62 | if (\is_resource($this->client)) { 63 | fclose($this->client); 64 | } 65 | } 66 | 67 | /** 68 | * Gets data for plotting the graph in the frontend. 69 | * Each row in $return['data'] will be one line in the graph. 70 | * The lines can be 71 | * * protocols - $sources must not contain more than one source (legend e.g. gateway_flows_udp, gateway_flows_tcp) 72 | * * sources - $protocols must not contain more than one protocol (legend e.g. gateway_traffic_icmp, othersource_traffic_icmp). 73 | * 74 | * @param int $start timestamp 75 | * @param int $end timestamp 76 | * @param array $sources subset of sources specified in settings 77 | * @param array $protocols UDP/TCP/ICMP/other 78 | * @param string $type flows/packets/traffic 79 | * 80 | * @return array in the following format: 81 | * 82 | * $return = array( 83 | * 'start' => 1490484600, // timestamp of first value 84 | * 'end' => 1490652000, // timestamp of last value 85 | * 'step' => 300, // resolution of the returned data in seconds. lowest value would probably be 300 = 5 minutes 86 | * 'data' => array( 87 | * 0 => array( 88 | * 'legend' => 'source_type_protocol', 89 | * 'data' => array( 90 | * 1490484600 => 33.998333333333, 91 | * 1490485200 => 37.005, ... 92 | * ) 93 | * ), 94 | * 1 => array( e.g. gateway_flows_udp ...) 95 | * ) 96 | * ); 97 | */ 98 | public function get_graph_data(int $start, int $end, array $sources, array $protocols, array $ports, string $type = 'flows', string $display = 'sources'): array|string { 99 | // TODO: Implement get_graph_data() method. 100 | return []; 101 | } 102 | 103 | /** 104 | * Gets the timestamps of the first and last entry in the datasource (for this specific source). 105 | * 106 | * @return array (timestampfirst, timestamplast) 107 | */ 108 | public function date_boundaries(string $source): array { 109 | // TODO: Implement date_boundaries() method. 110 | return []; 111 | } 112 | 113 | /** 114 | * Gets the timestamp of the last update of the datasource (for this specific source). 115 | */ 116 | public function last_update(string $source, int $port = 0): int { 117 | // TODO: Implement last_update() method. 118 | return 0; 119 | } 120 | 121 | /** 122 | * Gets the path where the datasource's data is stored. 123 | */ 124 | public function get_data_path(): string { 125 | // TODO: Implement get_data_path() method. 126 | return ''; 127 | } 128 | 129 | /** 130 | * Removes all existing data for every source in $sources. 131 | * If $sources is empty, remove all existing data. 132 | */ 133 | public function reset(array $sources): bool { 134 | // TODO: Implement reset() method. 135 | return true; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /backend/datasources/Datasource.php: -------------------------------------------------------------------------------- 1 | array( 10 | * 'source' => 'name_of_souce', 11 | * 'date_timestamp' => 000000000, 12 | * 'date_iso' => 'Ymd\THis', 13 | * 'fields' => array( 14 | * 'flows', 15 | * 'flows_tcp', 16 | * 'flows_udp', 17 | * 'flows_icmp', 18 | * 'flows_other', 19 | * 'packets', 20 | * 'packets_tcp', 21 | * 'packets_udp', 22 | * 'packets_icmp', 23 | * 'packets_other', 24 | * 'bytes', 25 | * 'bytes_tcp', 26 | * 'bytes_udp', 27 | * 'bytes_icmp', 28 | * 'bytes_other') 29 | * );. 30 | * 31 | * @return bool TRUE on success or FALSE on failure 32 | * 33 | * @throws \Exception on error 34 | */ 35 | public function write(array $data): bool; 36 | 37 | /** 38 | * Gets data for plotting the graph in the frontend. 39 | * Each row in $return['data'] will be a time point in the graph. 40 | * The lines can be 41 | * * protocols - $sources must not contain more than one source (legend e.g. gateway_flows_udp, gateway_flows_tcp) 42 | * * sources - $protocols must not contain more than one protocol (legend e.g. gateway_traffic_icmp, othersource_traffic_icmp) 43 | * * ports. 44 | * 45 | * @param int $start timestamp 46 | * @param int $end timestamp 47 | * @param array $sources subset of sources specified in settings 48 | * @param array $protocols UDP/TCP/ICMP/other 49 | * @param string $type flows/packets/traffic 50 | * @param string $display protocols/sources/ports 51 | * 52 | * @return array in the following format: 53 | * 54 | * $return = array( 55 | * 'start' => 1490484600, // timestamp of first value 56 | * 'end' => 1490652000, // timestamp of last value 57 | * 'step' => 300, // resolution of the returned data in seconds. lowest value would probably be 300 = 5 minutes 58 | * 'legend' => array('swi6_flows_tcp', 'gate_flows_tcp'), // legend describes the graph series 59 | * 'data' => array( 60 | * 1490484600 => array(33.998333333333, 22.4), // the values/measurements for this specific timestamp are in an array 61 | * 1490485200 => array(37.005, 132.8282), 62 | * ... 63 | * ) 64 | * ); 65 | */ 66 | public function get_graph_data( 67 | int $start, 68 | int $end, 69 | array $sources, 70 | array $protocols, 71 | array $ports, 72 | string $type = 'flows', 73 | string $display = 'sources', 74 | ): array|string; 75 | 76 | /** 77 | * Removes all existing data for every source in $sources. 78 | * If $sources is empty, remove all existing data. 79 | */ 80 | public function reset(array $sources): bool; 81 | 82 | /** 83 | * Gets the timestamps of the first and last entry in the datasource (for this specific source). 84 | * 85 | * @return array (timestampfirst, timestamplast) 86 | */ 87 | public function date_boundaries(string $source): array; 88 | 89 | /** 90 | * Gets the timestamp of the last update of the datasource (for this specific source). 91 | */ 92 | public function last_update(string $source, int $port = 0): int; 93 | 94 | /** 95 | * Gets the path where the datasource's data is stored. 96 | */ 97 | public function get_data_path(): string; 98 | } 99 | -------------------------------------------------------------------------------- /backend/datasources/Rrd.php: -------------------------------------------------------------------------------- 1 | d = Debug::getInstance(); 39 | 40 | if (!\function_exists('rrd_version')) { 41 | throw new \Exception('Please install the PECL rrd library.'); 42 | } 43 | } 44 | 45 | /** 46 | * Gets the timestamps of the first and last entry of this specific source. 47 | */ 48 | public function date_boundaries(string $source): array { 49 | $rrdFile = $this->get_data_path($source); 50 | 51 | return [rrd_first($rrdFile), rrd_last($rrdFile)]; 52 | } 53 | 54 | /** 55 | * Gets the timestamp of the last update of this specific source. 56 | * 57 | * @return int timestamp or false 58 | */ 59 | public function last_update(string $source = '', int $port = 0): int { 60 | $rrdFile = $this->get_data_path($source, $port); 61 | $last_update = rrd_last($rrdFile); 62 | 63 | // $this->d->log('Last update of ' . $rrdFile . ': ' . date('d.m.Y H:i', $last_update), LOG_DEBUG); 64 | return (int) $last_update; 65 | } 66 | 67 | /** 68 | * Create a new RRD file for a source. 69 | * 70 | * @param string $source e.g. gateway or server_xyz 71 | * @param bool $reset overwrites existing RRD file if true 72 | */ 73 | public function create(string $source, int $port = 0, bool $reset = false): bool { 74 | $rrdFile = $this->get_data_path($source, $port); 75 | 76 | // check if folder exists 77 | if (!file_exists(\dirname($rrdFile))) { 78 | mkdir(\dirname($rrdFile), 0o755, true); 79 | } 80 | 81 | // check if folder has correct access rights 82 | if (!is_writable(\dirname($rrdFile))) { 83 | $this->d->log('Error creating ' . $rrdFile . ': Not writable', \LOG_CRIT); 84 | 85 | return false; 86 | } 87 | // check if file already exists 88 | if (file_exists($rrdFile)) { 89 | if ($reset === true) { 90 | unlink($rrdFile); 91 | } else { 92 | $this->d->log('Error creating ' . $rrdFile . ': File already exists', \LOG_ERR); 93 | 94 | return false; 95 | } 96 | } 97 | 98 | $start = strtotime('3 years ago'); 99 | $starttime = (int) $start - ($start % 300); 100 | 101 | $creator = new \RRDCreator($rrdFile, (string) $starttime, 60 * 5); 102 | foreach ($this->fields as $field) { 103 | $creator->addDataSource($field . ':ABSOLUTE:600:U:U'); 104 | } 105 | foreach ($this->layout as $rra) { 106 | $creator->addArchive('AVERAGE:' . $rra); 107 | $creator->addArchive('MAX:' . $rra); 108 | } 109 | 110 | $saved = $creator->save(); 111 | if ($saved === false) { 112 | $this->d->log('Error saving RRD data structure to ' . $rrdFile, \LOG_ERR); 113 | } 114 | 115 | return $saved; 116 | } 117 | 118 | /** 119 | * Write to an RRD file with supplied data. 120 | * 121 | * @throws \Exception 122 | */ 123 | public function write(array $data): bool { 124 | $rrdFile = $this->get_data_path($data['source'], $data['port']); 125 | if (!file_exists($rrdFile)) { 126 | $this->create($data['source'], $data['port'], false); 127 | } 128 | 129 | $nearest = (int) $data['date_timestamp'] - ($data['date_timestamp'] % 300); 130 | $this->d->log('Writing to file ' . $rrdFile, \LOG_DEBUG); 131 | 132 | // write data 133 | $updater = new \RRDUpdater($rrdFile); 134 | 135 | return $updater->update($data['fields'], (string) $nearest); 136 | } 137 | 138 | /** 139 | * @param string $type flows/packets/traffic 140 | * @param string $display protocols/sources/ports 141 | */ 142 | public function get_graph_data( 143 | int $start, 144 | int $end, 145 | array $sources, 146 | array $protocols, 147 | array $ports, 148 | #[ExpectedValues(['flows', 'packets', 'bytes', 'bits'])] 149 | string $type = 'flows', 150 | #[ExpectedValues(['protocols', 'sources', 'ports'])] 151 | string $display = 'sources', 152 | ): array|string { 153 | $options = [ 154 | '--start', 155 | $start - ($start % 300), 156 | '--end', 157 | $end - ($end % 300), 158 | '--maxrows', 159 | 300, 160 | // number of values. works like the width value (in pixels) in rrd_graph 161 | // '--step', 1200, // by default, rrdtool tries to get data for each row. if you want rrdtool to get data at a one-hour resolution, set step to 3600. 162 | '--json', 163 | ]; 164 | 165 | $useBits = false; 166 | if ($type === 'bits') { 167 | $type = 'bytes'; 168 | $useBits = true; 169 | } 170 | 171 | if (empty($protocols)) { 172 | $protocols = ['tcp', 'udp', 'icmp', 'other']; 173 | } 174 | if (empty($sources)) { 175 | $sources = Config::$cfg['general']['sources']; 176 | } 177 | if (empty($ports)) { 178 | $ports = Config::$cfg['general']['ports']; 179 | } 180 | 181 | switch ($display) { 182 | case 'protocols': 183 | foreach ($protocols as $protocol) { 184 | $rrdFile = $this->get_data_path($sources[0]); 185 | $proto = ($protocol === 'any') ? '' : '_' . $protocol; 186 | $legend = array_filter([$protocol, $type, $sources[0]]); 187 | $options[] = 'DEF:data' . $sources[0] . $protocol . '=' . $rrdFile . ':' . $type . $proto . ':AVERAGE'; 188 | $options[] = 'XPORT:data' . $sources[0] . $protocol . ':' . implode('_', $legend); 189 | } 190 | break; 191 | case 'sources': 192 | foreach ($sources as $source) { 193 | $rrdFile = $this->get_data_path($source); 194 | $proto = ($protocols[0] === 'any') ? '' : '_' . $protocols[0]; 195 | $legend = array_filter([$source, $type, $protocols[0]]); 196 | $options[] = 'DEF:data' . $source . '=' . $rrdFile . ':' . $type . $proto . ':AVERAGE'; 197 | $options[] = 'XPORT:data' . $source . ':' . implode('_', $legend); 198 | } 199 | break; 200 | case 'ports': 201 | foreach ($ports as $port) { 202 | $source = ($sources[0] === 'any') ? '' : $sources[0]; 203 | $proto = ($protocols[0] === 'any') ? '' : '_' . $protocols[0]; 204 | $legend = array_filter([$port, $type, $source, $protocols[0]]); 205 | $rrdFile = $this->get_data_path($source, $port); 206 | $options[] = 'DEF:data' . $source . $port . '=' . $rrdFile . ':' . $type . $proto . ':AVERAGE'; 207 | $options[] = 'XPORT:data' . $source . $port . ':' . implode('_', $legend); 208 | } 209 | } 210 | 211 | ob_start(); 212 | $data = rrd_xport($options); 213 | $error = ob_get_clean(); // rrd_xport weirdly prints stuff on error 214 | 215 | if (!\is_array($data)) { 216 | return $error . '. ' . rrd_error(); 217 | } 218 | 219 | // remove invalid numbers and create processable array 220 | $output = [ 221 | 'data' => [], 222 | 'start' => $data['start'], 223 | 'end' => $data['end'], 224 | 'step' => $data['step'], 225 | 'legend' => [], 226 | ]; 227 | foreach ($data['data'] as $source) { 228 | $output['legend'][] = $source['legend']; 229 | foreach ($source['data'] as $date => $measure) { 230 | // ignore non-valid measures 231 | if (is_nan($measure)) { 232 | $measure = null; 233 | } 234 | 235 | if ($type === 'bytes' && $useBits) { 236 | $measure *= 8; 237 | } 238 | 239 | // add measure to output array 240 | if (\array_key_exists($date, $output['data'])) { 241 | $output['data'][$date][] = $measure; 242 | } else { 243 | $output['data'][$date] = [$measure]; 244 | } 245 | } 246 | } 247 | 248 | return $output; 249 | } 250 | 251 | /** 252 | * Creates a new database for every source/port combination. 253 | */ 254 | public function reset(array $sources): bool { 255 | $return = false; 256 | if (empty($sources)) { 257 | $sources = Config::$cfg['general']['sources']; 258 | } 259 | $ports = Config::$cfg['general']['ports']; 260 | $ports[] = 0; 261 | foreach ($ports as $port) { 262 | if ($port !== 0) { 263 | $return = $this->create('', $port, true); 264 | } 265 | if ($return === false) { 266 | return false; 267 | } 268 | 269 | foreach ($sources as $source) { 270 | $return = $this->create($source, $port, true); 271 | if ($return === false) { 272 | return false; 273 | } 274 | } 275 | } 276 | 277 | return true; 278 | } 279 | 280 | /** 281 | * Concatenates the path to the source's rrd file. 282 | */ 283 | public function get_data_path(string $source = '', int $port = 0): string { 284 | if ((int) $port === 0) { 285 | $port = ''; 286 | } else { 287 | $port = (empty($source)) ? $port : '_' . $port; 288 | } 289 | $path = Config::$path . \DIRECTORY_SEPARATOR . 'datasources' . \DIRECTORY_SEPARATOR . 'data' . \DIRECTORY_SEPARATOR . $source . $port . '.rrd'; 290 | 291 | if (!file_exists($path)) { 292 | $this->d->log('Was not able to find ' . $path, \LOG_INFO); 293 | } 294 | 295 | return $path; 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /backend/index.php: -------------------------------------------------------------------------------- 1 | log('Fatal: ' . $e->getMessage(), \LOG_ALERT); 22 | exit; 23 | } 24 | 25 | $folder = __DIR__; 26 | $lock_file = fopen($folder . '/nfsen-ng.pid', 'c'); 27 | $got_lock = flock($lock_file, \LOCK_EX | \LOCK_NB, $wouldblock); 28 | if ($lock_file === false || (!$got_lock && !$wouldblock)) { 29 | exit(128); 30 | } 31 | if (!$got_lock && $wouldblock) { 32 | exit(129); 33 | } 34 | 35 | // Lock acquired; let's write our PID to the lock file for the convenience 36 | // of humans who may wish to terminate the script. 37 | ftruncate($lock_file, 0); 38 | fwrite($lock_file, getmypid() . \PHP_EOL); 39 | 40 | // first import missed data if available 41 | $start = new DateTime(); 42 | $start->setDate(date('Y') - 3, (int) date('m'), (int) date('d')); 43 | $i = new Import(); 44 | $i->setQuiet(false); 45 | $i->setVerbose(true); 46 | $i->setProcessPorts(true); 47 | $i->setProcessPortsBySource(true); 48 | $i->setCheckLastUpdate(true); 49 | $i->start($start); 50 | 51 | $d->log('Starting periodic execution', \LOG_INFO); 52 | 53 | /* @phpstan-ignore-next-line */ 54 | while (1) { 55 | // next import in 30 seconds 56 | sleep(30); 57 | 58 | // import from last db update 59 | $i->start($start); 60 | } 61 | 62 | // all done; blank the PID file and explicitly release the lock 63 | /* @phpstan-ignore-next-line */ 64 | ftruncate($lock_file, 0); 65 | flock($lock_file, \LOCK_UN); 66 | -------------------------------------------------------------------------------- /backend/processor/Nfdump.php: -------------------------------------------------------------------------------- 1 | [], 11 | 'option' => [], 12 | 'format' => null, 13 | 'filter' => [], 14 | ]; 15 | private array $clean; 16 | private readonly Debug $d; 17 | public static ?self $_instance = null; 18 | 19 | public function __construct() { 20 | $this->d = Debug::getInstance(); 21 | $this->clean = $this->cfg; 22 | $this->reset(); 23 | } 24 | 25 | public static function getInstance(): self { 26 | if (!(self::$_instance instanceof self)) { 27 | self::$_instance = new self(); 28 | } 29 | 30 | return self::$_instance; 31 | } 32 | 33 | /** 34 | * Sets an option's value. 35 | */ 36 | public function setOption(string $option, $value): void { 37 | switch ($option) { 38 | case '-M': // set sources 39 | // only sources specified in settings allowed 40 | $queried_sources = explode(':', (string) $value); 41 | foreach ($queried_sources as $s) { 42 | if (!\in_array($s, Config::$cfg['general']['sources'], true)) { 43 | continue; 44 | } 45 | $this->cfg['env']['sources'][] = $s; 46 | } 47 | 48 | // cancel if no sources remain 49 | if (empty($this->cfg['env']['sources'])) { 50 | break; 51 | } 52 | 53 | // set sources path 54 | $this->cfg['option'][$option] = implode(\DIRECTORY_SEPARATOR, [ 55 | $this->cfg['env']['profiles-data'], 56 | $this->cfg['env']['profile'], 57 | implode(':', $this->cfg['env']['sources']), 58 | ]); 59 | 60 | break; 61 | case '-R': // set path 62 | $this->cfg['option'][$option] = $this->convert_date_to_path($value[0], $value[1]); 63 | break; 64 | case '-o': // set output format 65 | $this->cfg['format'] = $value; 66 | break; 67 | default: 68 | $this->cfg['option'][$option] = $value; 69 | $this->cfg['option']['-o'] = 'csv'; // always get parsable data todo user-selectable? calculations bps/bpp/pps not in csv 70 | break; 71 | } 72 | } 73 | 74 | /** 75 | * Sets a filter's value. 76 | */ 77 | public function setFilter(string $filter): void { 78 | $this->cfg['filter'] = $filter; 79 | } 80 | 81 | /** 82 | * Executes the nfdump command, tries to throw an exception based on the return code. 83 | * 84 | * @throws \Exception 85 | */ 86 | public function execute(): array { 87 | $output = []; 88 | $processes = []; 89 | $return = ''; 90 | $timer = microtime(true); 91 | $filter = (empty($this->cfg['filter'])) ? '' : ' ' . escapeshellarg((string) $this->cfg['filter']); 92 | $command = $this->cfg['env']['bin'] . ' ' . $this->flatten($this->cfg['option']) . $filter . ' 2>&1'; 93 | $this->d->log('Trying to execute ' . $command, \LOG_DEBUG); 94 | 95 | // check for already running nfdump processes 96 | exec('ps -eo user,pid,args | grep -v grep | grep `whoami` | grep "' . $this->cfg['env']['bin'] . '"', $processes); 97 | if (\count($processes) / 2 > (int) Config::$cfg['nfdump']['max-processes']) { 98 | throw new \Exception('There already are ' . \count($processes) / 2 . ' processes of NfDump running!'); 99 | } 100 | 101 | // execute nfdump 102 | exec($command, $output, $return); 103 | 104 | // prevent logging the command usage description 105 | if (isset($output[0]) && preg_match('/^usage/i', $output[0])) { 106 | $output = []; 107 | } 108 | 109 | switch ($return) { 110 | case 127: 111 | throw new \Exception('NfDump: Failed to start process. Is nfdump installed?
Output: ' . implode(' ', $output)); 112 | case 255: 113 | throw new \Exception('NfDump: Initialization failed. ' . $command . '
Output: ' . implode(' ', $output)); 114 | case 254: 115 | throw new \Exception('NfDump: Error in filter syntax.
Output: ' . implode(' ', $output)); 116 | case 250: 117 | throw new \Exception('NfDump: Internal error.
Output: ' . implode(' ', $output)); 118 | } 119 | 120 | // add command to output 121 | array_unshift($output, $command); 122 | 123 | // if last element contains a colon, it's not a csv 124 | if (str_contains($output[\count($output) - 1], ':')) { 125 | return $output; // return output if it is a flows/packets/bytes dump 126 | } 127 | 128 | // remove the 3 summary lines at the end of the csv output 129 | $output = \array_slice($output, 0, -3); 130 | 131 | // slice csv (only return the fields actually wanted) 132 | $field_ids_active = []; 133 | $parsed_header = false; 134 | $format = false; 135 | if (isset($this->cfg['format'])) { 136 | $format = $this->get_output_format($this->cfg['format']); 137 | } 138 | 139 | foreach ($output as $i => &$line) { 140 | if ($i === 0) { 141 | continue; 142 | } // skip nfdump command 143 | $line = str_getcsv($line, ','); 144 | $temp_line = []; 145 | 146 | if (\count($line) === 1 || preg_match('/limit/', $line[0]) || preg_match('/error/', $line[0])) { // probably an error message or warning. add to command 147 | $output[0] .= '
' . $line[0] . ''; 148 | unset($output[$i]); 149 | continue; 150 | } 151 | if (!\is_array($format)) { 152 | $format = $line; 153 | } // set first valid line as header if not already defined 154 | 155 | foreach ($line as $field_id => $field) { 156 | // heading has the field identifiers. fill $fields_active with all active fields 157 | if ($parsed_header === false) { 158 | if (\in_array($field, $format, true)) { 159 | $field_ids_active[array_search($field, $format, true)] = $field_id; 160 | } 161 | } 162 | 163 | // remove field if not in $fields_active 164 | if (\in_array($field_id, $field_ids_active, true)) { 165 | $temp_line[array_search($field_id, $field_ids_active, true)] = $field; 166 | } 167 | } 168 | 169 | $parsed_header = true; 170 | ksort($temp_line); 171 | $line = array_values($temp_line); 172 | } 173 | 174 | // add execution time to output 175 | $output[0] .= '
Execution time: ' . round(microtime(true) - $timer, 3) . ' seconds'; 176 | 177 | return array_values($output); 178 | } 179 | 180 | /** 181 | * Concatenates key and value of supplied array. 182 | */ 183 | private function flatten(array $array): string { 184 | $output = ''; 185 | 186 | foreach ($array as $key => $value) { 187 | if ($value === null) { 188 | $output .= $key . ' '; 189 | } else { 190 | $output .= \is_int($key) ?: $key . ' ' . escapeshellarg((string) $value) . ' '; 191 | } 192 | } 193 | 194 | return $output; 195 | } 196 | 197 | /** 198 | * Reset config. 199 | */ 200 | public function reset(): void { 201 | $this->clean['env'] = [ 202 | 'bin' => Config::$cfg['nfdump']['binary'], 203 | 'profiles-data' => Config::$cfg['nfdump']['profiles-data'], 204 | 'profile' => Config::$cfg['nfdump']['profile'], 205 | 'sources' => [], 206 | ]; 207 | $this->cfg = $this->clean; 208 | } 209 | 210 | /** 211 | * Converts a time range to a nfcapd file range 212 | * Ensures that files actually exist. 213 | * 214 | * @throws \Exception 215 | */ 216 | public function convert_date_to_path(int $datestart, int $dateend): string { 217 | $start = new \DateTime(); 218 | $end = new \DateTime(); 219 | $start->setTimestamp((int) $datestart - ($datestart % 300)); 220 | $end->setTimestamp((int) $dateend - ($dateend % 300)); 221 | $filestart = $fileend = '-'; 222 | $filestartexists = false; 223 | $fileendexists = false; 224 | $sourcepath = $this->cfg['env']['profiles-data'] . \DIRECTORY_SEPARATOR . $this->cfg['env']['profile'] . \DIRECTORY_SEPARATOR; 225 | 226 | // if start file does not exist, increment by 5 minutes and try again 227 | while ($filestartexists === false) { 228 | if ($start >= $end) { 229 | break; 230 | } 231 | 232 | foreach ($this->cfg['env']['sources'] as $source) { 233 | if (file_exists($sourcepath . $source . \DIRECTORY_SEPARATOR . $filestart)) { 234 | $filestartexists = true; 235 | } 236 | } 237 | 238 | $pathstart = $start->format('Y/m/d') . \DIRECTORY_SEPARATOR; 239 | $filestart = $pathstart . 'nfcapd.' . $start->format('YmdHi'); 240 | $start->add(new \DateInterval('PT5M')); 241 | } 242 | 243 | // if end file does not exist, subtract by 5 minutes and try again 244 | while ($fileendexists === false) { 245 | if ($end === $start) { // strict comparison won't work 246 | $fileend = $filestart; 247 | break; 248 | } 249 | 250 | foreach ($this->cfg['env']['sources'] as $source) { 251 | if (file_exists($sourcepath . $source . \DIRECTORY_SEPARATOR . $fileend)) { 252 | $fileendexists = true; 253 | } 254 | } 255 | 256 | $pathend = $end->format('Y/m/d') . \DIRECTORY_SEPARATOR; 257 | $fileend = $pathend . 'nfcapd.' . $end->format('YmdHi'); 258 | $end->sub(new \DateInterval('PT5M')); 259 | } 260 | 261 | return $filestart . \PATH_SEPARATOR . $fileend; 262 | } 263 | 264 | public function get_output_format($format): array { 265 | // todo calculations like bps/pps? flows? concatenate sa/sp to sap? 266 | return match ($format) { 267 | 'line' => ['ts', 'td', 'pr', 'sa', 'sp', 'da', 'dp', 'ipkt', 'ibyt', 'fl'], 268 | 'long' => ['ts', 'td', 'pr', 'sa', 'sp', 'da', 'dp', 'flg', 'stos', 'dtos', 'ipkt', 'ibyt', 'fl'], 269 | 'extended' => ['ts', 'td', 'pr', 'sa', 'sp', 'da', 'dp', 'ipkt', 'ibyt', 'ibps', 'ipps', 'ibpp'], 270 | 'full' => ['ts', 'te', 'td', 'sa', 'da', 'sp', 'dp', 'pr', 'flg', 'fwd', 'stos', 'ipkt', 'ibyt', 'opkt', 'obyt', 'in', 'out', 'sas', 'das', 'smk', 'dmk', 'dtos', 'dir', 'nh', 'nhb', 'svln', 'dvln', 'ismc', 'odmc', 'idmc', 'osmc', 'mpls1', 'mpls2', 'mpls3', 'mpls4', 'mpls5', 'mpls6', 'mpls7', 'mpls8', 'mpls9', 'mpls10', 'cl', 'sl', 'al', 'ra', 'eng', 'exid', 'tr'], 271 | default => explode(' ', str_replace(['fmt:', '%'], '', (string) $format)), 272 | }; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /backend/processor/Processor.php: -------------------------------------------------------------------------------- 1 | [ 12 | 'ports' => [ 13 | 80, 22, 53, 14 | ], 15 | 'sources' => [ 16 | 'source1', 'source2', 17 | ], 18 | 'filters' => [ 19 | 'proto udp', 20 | 'proto tcp', 21 | ], 22 | 'formats' => [ 23 | 'external_interfaces' => '%ts %td %pr %in %out %sa %sp %da %dp %ipkt %ibyt %opkt %obyt %flg', 24 | ], 25 | 'db' => 'RRD', 26 | 'processor' => 'NfDump', 27 | ], 28 | 'frontend' => [ 29 | 'reload_interval' => 60, 30 | 'defaults' => [ 31 | 'view' => 'graphs', // graphs, flows, statistics 32 | 'graphs' => [ 33 | 'display' => 'sources', // sources, protocols, ports 34 | 'datatype' => 'flows', // flows, packets, traffic 35 | 'protocols' => ['any'], // any, tcp, udp, icmp, others (multiple possible if display=protocols) 36 | ], 37 | 'flows' => [ 38 | 'limit' => 50, 39 | ], 40 | 'statistics' => [ 41 | 'order_by' => 'bytes', 42 | ], 43 | 'table'=> [ 44 | 'hidden_fields' => [ 45 | 'flg', 'fwd', 'in', 'out', 'sas', 'das' 46 | ], 47 | ] 48 | ], 49 | ], 50 | 'nfdump' => [ 51 | 'binary' => '/usr/bin/nfdump', 52 | 'profiles-data' => '/var/nfdump/profiles-data', 53 | 'profile' => 'live', 54 | 'max-processes' => 1, // maximum number of concurrently running nfdump processes 55 | ], 56 | 'db' => [ 57 | 'Akumuli' => [ 58 | // 'host' => 'localhost', 59 | // 'port' => 8282, 60 | ], 61 | 'RRD' => [], 62 | ], 63 | 'log' => [ 64 | 'priority' => \LOG_INFO, // LOG_DEBUG is very talkative! 65 | ], 66 | ]; 67 | -------------------------------------------------------------------------------- /backend/vendor/ProgressBar.php: -------------------------------------------------------------------------------- 1 | "\r:message::padding:%.01f%% %2\$d/%3\$d ETC: %4\$s. Elapsed: %5\$s [%6\$s]", 'message' => 'Running', 'size' => 30, 'width' => null]; 30 | 31 | /** 32 | * Runtime options 33 | */ 34 | protected static $options = []; 35 | 36 | /** 37 | * How much have we done already 38 | */ 39 | protected static $done = 0; 40 | 41 | /** 42 | * The format string used for the rendered status bar - see $defaults 43 | */ 44 | protected static $format; 45 | 46 | /** 47 | * message to display prefixing the progress bar text 48 | */ 49 | protected static $message; 50 | 51 | /** 52 | * How many chars to use for the progress bar itself. Not to be confused with $width 53 | */ 54 | protected static $size = 30; 55 | 56 | /** 57 | * When did we start (timestamp) 58 | */ 59 | protected static $start; 60 | 61 | /** 62 | * The width in characters the whole rendered string must fit in. defaults to the width of the 63 | * terminal window 64 | */ 65 | protected static $width; 66 | 67 | /** 68 | * What's the total number of times we're going to call set 69 | */ 70 | protected static $total; 71 | 72 | /** 73 | * Show a progress bar, actually not usually called explicitly. Called by next() 74 | * 75 | * @param int $done what fraction of $total to set as progress uses internal counter if not passed 76 | * 77 | * @static 78 | * @return string, the formatted progress bar prefixed with a carriage return 79 | */ 80 | public static function display($done = null) 81 | { 82 | if ($done) { 83 | self::$done = $done; 84 | } 85 | 86 | $now = time(); 87 | 88 | if (self::$total) { 89 | $fractionComplete = (double) (self::$done / self::$total); 90 | } else { 91 | $fractionComplete = 0; 92 | } 93 | 94 | $bar = floor($fractionComplete * self::$size); 95 | $barSize = min($bar, self::$size); 96 | 97 | $barContents = str_repeat('=', $barSize); 98 | if ($bar < self::$size) { 99 | $barContents .= '>'; 100 | $barContents .= str_repeat(' ', self::$size - $barSize); 101 | } elseif ($fractionComplete > 1) { 102 | $barContents .= '!'; 103 | } else { 104 | $barContents .= '='; 105 | } 106 | 107 | $percent = number_format($fractionComplete * 100, 1); 108 | 109 | $elapsed = $now - self::$start; 110 | if (self::$done) { 111 | $rate = $elapsed / self::$done; 112 | } else { 113 | $rate = 0; 114 | } 115 | $left = self::$total - self::$done; 116 | $etc = round($rate * $left, 2); 117 | 118 | if (self::$done) { 119 | $etcNowText = '< 1 sec'; 120 | } else { 121 | $etcNowText = '???'; 122 | } 123 | $timeRemaining = self::humanTime($etc, $etcNowText); 124 | $timeElapsed = self::humanTime($elapsed); 125 | 126 | $return = sprintf( 127 | self::$format, 128 | $percent, 129 | self::$done, 130 | self::$total, 131 | $timeRemaining, 132 | $timeElapsed, 133 | $barContents 134 | ); 135 | 136 | $width = strlen(preg_replace('@(?:\r|:\w+:)@', '', $return)); 137 | 138 | if (strlen((string) self::$message) > ((int)self::$width - (int)$width - 3)) { 139 | $message = substr((string) self::$message, 0, ((int)self::$width - (int)$width - 4)) . '...'; 140 | $padding = ''; 141 | } else { 142 | $message = self::$message; 143 | $width += strlen((string) $message); 144 | $padding = str_repeat(' ', ((int)self::$width - (int)$width)); 145 | } 146 | 147 | $return = str_replace(':message:', $message, $return); 148 | $return = str_replace(':padding:', $padding, $return); 149 | 150 | return $return; 151 | } 152 | 153 | /** 154 | * reset internal state, and send a new line so that the progress bar text is "finished" 155 | * 156 | * @static 157 | * @return string, a new line 158 | */ 159 | public static function finish() 160 | { 161 | self::reset(); 162 | return "\n"; 163 | } 164 | 165 | /** 166 | * Increment the internal counter, and returns the result of display 167 | * 168 | * @param int $inc Amount to increment the internal counter 169 | * @param string $message If passed, overrides the existing message 170 | * 171 | * @static 172 | * @return string - the progress bar 173 | */ 174 | public static function next($inc = 1, $message = '') 175 | { 176 | self::$done += $inc; 177 | 178 | if ($message) { 179 | self::$message = $message; 180 | } 181 | 182 | return self::display(); 183 | } 184 | 185 | /** 186 | * Called by start and finish 187 | * 188 | * @param array $options array 189 | * 190 | * @static 191 | * @return void 192 | */ 193 | public static function reset(array $options = []) 194 | { 195 | $options = array_merge(self::$defaults, $options); 196 | 197 | if (empty($options['done'])) { 198 | $options['done'] = 0; 199 | } 200 | if (empty($options['start'])) { 201 | $options['start'] = time(); 202 | } 203 | if (empty($options['total'])) { 204 | $options['total'] = 0; 205 | } 206 | 207 | self::$done = $options['done']; 208 | self::$format = $options['format']; 209 | self::$message = $options['message']; 210 | self::$size = $options['size']; 211 | self::$start = $options['start']; 212 | self::$total = $options['total']; 213 | self::setWidth($options['width']); 214 | } 215 | 216 | /** 217 | * change the message to be used the next time the display method is called 218 | * 219 | * @param string $message the string to display 220 | * 221 | * @static 222 | * @return void 223 | */ 224 | public static function setMessage($message = '') 225 | { 226 | self::$message = $message; 227 | } 228 | 229 | /** 230 | * change the total on a running progress bar 231 | * 232 | * @param int|string $total the new number of times we're expecting to run for 233 | * 234 | * @static 235 | * @return void 236 | */ 237 | public static function setTotal($total = '') 238 | { 239 | self::$total = $total; 240 | } 241 | 242 | /** 243 | * Initialize a progress bar 244 | * 245 | * @param int|null $total number of times we're going to call set 246 | * @param string $message message to prefix the bar with 247 | * @param array $options overrides for default options 248 | * 249 | * @static 250 | * @return string - the progress bar string with 0 progress 251 | */ 252 | public static function start(?int $total = null, string $message = '', array $options = []) 253 | { 254 | if ($message) { 255 | $options['message'] = $message; 256 | } 257 | $options['total'] = $total; 258 | $options['start'] = time(); 259 | self::reset($options); 260 | 261 | return self::display(); 262 | } 263 | 264 | /** 265 | * Convert a number of seconds into something human readable like "2 days, 4 hrs" 266 | * 267 | * @param int|float $seconds how far in the future/past to display 268 | * @param string $nowText if there are no seconds, what text to display 269 | * 270 | * @static 271 | * @return string representation of the time 272 | */ 273 | protected static function humanTime($seconds, string $nowText = '< 1 sec') 274 | { 275 | $prefix = ''; 276 | if ($seconds < 0) { 277 | $prefix = '- '; 278 | $seconds = -$seconds; 279 | } 280 | 281 | $days = $hours = $minutes = 0; 282 | 283 | if ($seconds >= 86400) { 284 | $days = (int) ($seconds / 86400); 285 | $seconds = $seconds - $days * 86400; 286 | } 287 | if ($seconds >= 3600) { 288 | $hours = (int) ($seconds / 3600); 289 | $seconds = $seconds - $hours * 3600; 290 | } 291 | if ($seconds >= 60) { 292 | $minutes = (int) ($seconds / 60); 293 | $seconds = $seconds - $minutes * 60; 294 | } 295 | $seconds = (int) $seconds; 296 | 297 | $return = []; 298 | 299 | if ($days) { 300 | $return[] = "$days days"; 301 | } 302 | if ($hours) { 303 | $return[] = "$hours hrs"; 304 | } 305 | if ($minutes) { 306 | $return[] = "$minutes mins"; 307 | } 308 | if ($seconds) { 309 | $return[] = "$seconds secs"; 310 | } 311 | 312 | if (!$return) { 313 | return $nowText; 314 | } 315 | return $prefix . implode( ', ', array_slice($return, 0, 2)); 316 | } 317 | 318 | /** 319 | * Set the width the rendered text must fit in 320 | * 321 | * @param int $width passed in options 322 | * 323 | * @static 324 | * @return void 325 | */ 326 | protected static function setWidth($width = null) 327 | { 328 | if ($width === null) { 329 | if (DIRECTORY_SEPARATOR === '/' && getenv("TERM")) { 330 | $width = `tput cols`; 331 | } 332 | if ($width < 80) { 333 | $width = 80; 334 | } 335 | } 336 | self::$width = $width; 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mbolli/nfsen-ng", 3 | "description": "Responsive NetFlow visualizer built on top of nfdump tools", 4 | "license": "Apache-2.0", 5 | "minimum-stability": "dev", 6 | "prefer-stable": true, 7 | "require": { 8 | "php": "^8.1", 9 | "ext-mbstring": "*", 10 | "ext-rrd": "*" 11 | }, 12 | "require-dev": { 13 | "friendsofphp/php-cs-fixer": "3.x", 14 | "jetbrains/phpstorm-attributes": "^1.0", 15 | "phpstan/phpstan": "^1.9", 16 | "rector/rector": "^0.19.0", 17 | "vimeo/psalm": "^5.4" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "mbolli\\nfsen_ng\\": "backend/" 22 | } 23 | }, 24 | "scripts": { 25 | "before-commit": [ 26 | "@fix", 27 | "@test-phpstan", 28 | "@test-psalm" 29 | ], 30 | "lint": "@fix --dry-run", 31 | "lint-diff": "@fix --dry-run --diff", 32 | "fix": "php-cs-fixer fix backend --ansi --allow-risky=yes -v --config=./.php-cs-fixer.php", 33 | "test-phpstan": "phpstan analyse backend -l 5 -a backend/settings/settings.php", 34 | "test-psalm": "psalm --php-version=8.2 --threads=1 --no-diff" 35 | }, 36 | "authors": [ 37 | { 38 | "name": "Michael Bolli", 39 | "email": "michael@bolli.us" 40 | } 41 | ], 42 | "config": { 43 | "sort-packages": true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/css/dygraph.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Default styles for the dygraphs charting library. 3 | */ 4 | 5 | .dygraph-legend { 6 | position: absolute; 7 | font-size: 14px; 8 | z-index: 10; 9 | width: 250px; /* labelsDivWidth */ 10 | /* 11 | dygraphs determines these based on the presence of chart labels. 12 | It might make more sense to create a wrapper div around the chart proper. 13 | top: 0px; 14 | right: 2px; 15 | */ 16 | background: white; 17 | line-height: normal; 18 | text-align: left; 19 | overflow: hidden; 20 | } 21 | 22 | .dygraph-legend[dir="rtl"] { 23 | text-align: right; 24 | } 25 | 26 | /* styles for a solid line in the legend */ 27 | .dygraph-legend-line { 28 | display: inline-block; 29 | position: relative; 30 | bottom: .5ex; 31 | padding-left: 1em; 32 | height: 1px; 33 | border-bottom-width: 2px; 34 | border-bottom-style: solid; 35 | /* border-bottom-color is set based on the series color */ 36 | } 37 | 38 | /* styles for a dashed line in the legend, e.g. when strokePattern is set */ 39 | .dygraph-legend-dash { 40 | display: inline-block; 41 | position: relative; 42 | bottom: .5ex; 43 | height: 1px; 44 | border-bottom-width: 2px; 45 | border-bottom-style: solid; 46 | /* border-bottom-color is set based on the series color */ 47 | /* margin-right is set based on the stroke pattern */ 48 | /* padding-left is set based on the stroke pattern */ 49 | } 50 | 51 | .dygraph-roller { 52 | position: absolute; 53 | z-index: 10; 54 | } 55 | 56 | /* This class is shared by all annotations, including those with icons */ 57 | .dygraph-annotation { 58 | position: absolute; 59 | z-index: 10; 60 | overflow: hidden; 61 | } 62 | 63 | /* This class only applies to annotations without icons */ 64 | /* Old class name: .dygraphDefaultAnnotation */ 65 | .dygraph-default-annotation { 66 | border: 1px solid black; 67 | background-color: white; 68 | text-align: center; 69 | } 70 | 71 | .dygraph-axis-label { 72 | /* position: absolute; */ 73 | /* font-size: 14px; */ 74 | z-index: 10; 75 | line-height: normal; 76 | overflow: hidden; 77 | color: black; /* replaces old axisLabelColor option */ 78 | } 79 | 80 | .dygraph-axis-label-x { 81 | } 82 | 83 | .dygraph-axis-label-y { 84 | } 85 | 86 | .dygraph-axis-label-y2 { 87 | } 88 | 89 | .dygraph-title { 90 | font-weight: bold; 91 | z-index: 10; 92 | text-align: center; 93 | /* font-size: based on titleHeight option */ 94 | } 95 | 96 | .dygraph-xlabel { 97 | text-align: center; 98 | /* font-size: based on xLabelHeight option */ 99 | } 100 | 101 | /* For y-axis label */ 102 | .dygraph-label-rotate-left { 103 | text-align: center; 104 | /* See http://caniuse.com/#feat=transforms2d */ 105 | transform: rotate(90deg); 106 | -webkit-transform: rotate(90deg); 107 | -moz-transform: rotate(90deg); 108 | -o-transform: rotate(90deg); 109 | -ms-transform: rotate(90deg); 110 | } 111 | 112 | /* For y2-axis label */ 113 | .dygraph-label-rotate-right { 114 | text-align: center; 115 | /* See http://caniuse.com/#feat=transforms2d */ 116 | transform: rotate(-90deg); 117 | -webkit-transform: rotate(-90deg); 118 | -moz-transform: rotate(-90deg); 119 | -o-transform: rotate(-90deg); 120 | -ms-transform: rotate(-90deg); 121 | } 122 | -------------------------------------------------------------------------------- /frontend/css/footable.bootstrap.min.css: -------------------------------------------------------------------------------- 1 | table.footable-details,table.footable>thead>tr.footable-filtering>th div.form-group{margin-bottom:0}table.footable,table.footable-details{position:relative;width:100%;border-spacing:0;border-collapse:collapse}table.footable-hide-fouc{display:none}table>tbody>tr>td>span.footable-toggle{margin-right:8px;opacity:.3}table>tbody>tr>td>span.footable-toggle.last-column{margin-left:8px;float:right}table.table-condensed>tbody>tr>td>span.footable-toggle{margin-right:5px}table.footable-details>tbody>tr>th:nth-child(1){min-width:40px;width:120px}table.footable-details>tbody>tr>td:nth-child(2){word-break:break-all}table.footable-details>tbody>tr:first-child>td,table.footable-details>tbody>tr:first-child>th,table.footable-details>tfoot>tr:first-child>td,table.footable-details>tfoot>tr:first-child>th,table.footable-details>thead>tr:first-child>td,table.footable-details>thead>tr:first-child>th{border-top-width:0}table.footable-details.table-bordered>tbody>tr:first-child>td,table.footable-details.table-bordered>tbody>tr:first-child>th,table.footable-details.table-bordered>tfoot>tr:first-child>td,table.footable-details.table-bordered>tfoot>tr:first-child>th,table.footable-details.table-bordered>thead>tr:first-child>td,table.footable-details.table-bordered>thead>tr:first-child>th{border-top-width:1px}div.footable-loader{vertical-align:middle;text-align:center;height:300px;position:relative}div.footable-loader>span.fooicon{display:inline-block;opacity:.3;font-size:30px;line-height:32px;width:32px;height:32px;margin-top:-16px;margin-left:-16px;position:absolute;top:50%;left:50%;-webkit-animation:fooicon-spin-r 2s infinite linear;animation:fooicon-spin-r 2s infinite linear}table.footable>tbody>tr.footable-empty>td{vertical-align:middle;text-align:center;font-size:30px}table.footable>tbody>tr>td,table.footable>tbody>tr>th{display:none}table.footable>tbody>tr.footable-detail-row>td,table.footable>tbody>tr.footable-detail-row>th,table.footable>tbody>tr.footable-empty>td,table.footable>tbody>tr.footable-empty>th{display:table-cell}@-webkit-keyframes fooicon-spin-r{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fooicon-spin-r{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fooicon{position:relative;top:1px;display:inline-block;font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fooicon:after,.fooicon:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.fooicon-loader:before{content:"\25CC"}.fooicon-plus:before{content:"\2b"}.fooicon-minus:before{content:"\2212"}.fooicon-search:before{content:"\2315"}.fooicon-remove:before{content:"\e014"}.fooicon-sort:before{content:"\21D5"}.fooicon-sort-asc:before{content:"\25B2"}.fooicon-sort-desc:before{content:"\25BC"}.fooicon-pencil:before{content:"\270f"}.fooicon-trash:before{content:"\1F5D1"}.fooicon-eye-close:before{content:"\e106"}.fooicon-flash:before{content:"\1F5F2"}.fooicon-cog:before{content:"\2699"}.fooicon-stats:before{content:"\e185"}table.footable>thead>tr.footable-filtering>th{border-bottom-width:1px;font-weight:400}.footable-filtering-external.footable-filtering-right,table.footable.footable-filtering-right>thead>tr.footable-filtering>th,table.footable>thead>tr.footable-filtering>th{text-align:right}.footable-filtering-external.footable-filtering-left,table.footable.footable-filtering-left>thead>tr.footable-filtering>th{text-align:left}.footable-filtering-external.footable-filtering-center,.footable-paging-external.footable-paging-center,table.footable-paging-center>tfoot>tr.footable-paging>td,table.footable.footable-filtering-center>thead>tr.footable-filtering>th,table.footable>tfoot>tr.footable-paging>td{text-align:center}table.footable>thead>tr.footable-filtering>th div.form-group+div.form-group{margin-top:5px}table.footable>thead>tr.footable-filtering>th div.input-group{width:100%}.footable-filtering-external ul.dropdown-menu>li>a.checkbox,table.footable>thead>tr.footable-filtering>th ul.dropdown-menu>li>a.checkbox{margin:0;display:block;position:relative}.footable-filtering-external ul.dropdown-menu>li>a.checkbox>label,table.footable>thead>tr.footable-filtering>th ul.dropdown-menu>li>a.checkbox>label{display:block;padding-left:20px}.footable-filtering-external ul.dropdown-menu>li>a.checkbox input[type=checkbox],table.footable>thead>tr.footable-filtering>th ul.dropdown-menu>li>a.checkbox input[type=checkbox]{position:absolute;margin-left:-20px}@media (min-width:768px){table.footable>thead>tr.footable-filtering>th div.input-group{width:auto}table.footable>thead>tr.footable-filtering>th div.form-group{margin-left:2px;margin-right:2px}table.footable>thead>tr.footable-filtering>th div.form-group+div.form-group{margin-top:0}}table.footable>tbody>tr>td.footable-sortable,table.footable>tbody>tr>th.footable-sortable,table.footable>tfoot>tr>td.footable-sortable,table.footable>tfoot>tr>th.footable-sortable,table.footable>thead>tr>td.footable-sortable,table.footable>thead>tr>th.footable-sortable{position:relative;padding-right:30px;cursor:pointer}td.footable-sortable>span.fooicon,th.footable-sortable>span.fooicon{position:absolute;right:6px;top:50%;margin-top:-7px;opacity:0;transition:opacity .3s ease-in}td.footable-sortable.footable-asc>span.fooicon,td.footable-sortable.footable-desc>span.fooicon,td.footable-sortable:hover>span.fooicon,th.footable-sortable.footable-asc>span.fooicon,th.footable-sortable.footable-desc>span.fooicon,th.footable-sortable:hover>span.fooicon{opacity:1}table.footable-sorting-disabled td.footable-sortable.footable-asc>span.fooicon,table.footable-sorting-disabled td.footable-sortable.footable-desc>span.fooicon,table.footable-sorting-disabled td.footable-sortable:hover>span.fooicon,table.footable-sorting-disabled th.footable-sortable.footable-asc>span.fooicon,table.footable-sorting-disabled th.footable-sortable.footable-desc>span.fooicon,table.footable-sorting-disabled th.footable-sortable:hover>span.fooicon{opacity:0;visibility:hidden}.footable-paging-external ul.pagination,table.footable>tfoot>tr.footable-paging>td>ul.pagination{margin:10px 0 0}.footable-paging-external span.label,table.footable>tfoot>tr.footable-paging>td>span.label{display:inline-block;margin:0 0 10px;padding:4px 10px}.footable-paging-external.footable-paging-left,table.footable-paging-left>tfoot>tr.footable-paging>td{text-align:left}.footable-paging-external.footable-paging-right,table.footable-editing-right td.footable-editing,table.footable-editing-right tr.footable-editing,table.footable-paging-right>tfoot>tr.footable-paging>td{text-align:right}ul.pagination>li.footable-page{display:none}ul.pagination>li.footable-page.visible{display:inline}td.footable-editing{width:90px;max-width:90px}table.footable-editing-no-delete td.footable-editing,table.footable-editing-no-edit td.footable-editing,table.footable-editing-no-view td.footable-editing{width:70px;max-width:70px}table.footable-editing-no-delete.footable-editing-no-view td.footable-editing,table.footable-editing-no-edit.footable-editing-no-delete td.footable-editing,table.footable-editing-no-edit.footable-editing-no-view td.footable-editing{width:50px;max-width:50px}table.footable-editing-no-edit.footable-editing-no-delete.footable-editing-no-view td.footable-editing,table.footable-editing-no-edit.footable-editing-no-delete.footable-editing-no-view th.footable-editing{width:0;max-width:0;display:none!important}table.footable-editing-left td.footable-editing,table.footable-editing-left tr.footable-editing{text-align:left}table.footable-editing button.footable-add,table.footable-editing button.footable-hide,table.footable-editing-show button.footable-show,table.footable-editing.footable-editing-always-show button.footable-hide,table.footable-editing.footable-editing-always-show button.footable-show,table.footable-editing.footable-editing-always-show.footable-editing-no-add tr.footable-editing{display:none}table.footable-editing.footable-editing-always-show button.footable-add,table.footable-editing.footable-editing-show button.footable-add,table.footable-editing.footable-editing-show button.footable-hide{display:inline-block} 2 | -------------------------------------------------------------------------------- /frontend/css/ion.rangeSlider.css: -------------------------------------------------------------------------------- 1 | /* Ion.RangeSlider 2 | // css version 2.0.3 3 | // © 2013-2014 Denis Ineshin | IonDen.com 4 | // ===================================================================================================================*/ 5 | 6 | /* ===================================================================================================================== 7 | // RangeSlider */ 8 | 9 | .irs { 10 | position: relative; display: block; 11 | -webkit-touch-callout: none; 12 | -webkit-user-select: none; 13 | -khtml-user-select: none; 14 | -moz-user-select: none; 15 | -ms-user-select: none; 16 | user-select: none; 17 | } 18 | .irs-line { 19 | position: relative; display: block; 20 | overflow: hidden; 21 | outline: none !important; 22 | } 23 | .irs-line-left, .irs-line-mid, .irs-line-right { 24 | position: absolute; display: block; 25 | top: 0; 26 | } 27 | .irs-line-left { 28 | left: 0; width: 11%; 29 | } 30 | .irs-line-mid { 31 | left: 9%; width: 82%; 32 | } 33 | .irs-line-right { 34 | right: 0; width: 11%; 35 | } 36 | 37 | .irs-bar { 38 | position: absolute; display: block; 39 | left: 0; width: 0; 40 | } 41 | .irs-bar-edge { 42 | position: absolute; display: block; 43 | top: 0; left: 0; 44 | } 45 | 46 | .irs-shadow { 47 | position: absolute; display: none; 48 | left: 0; width: 0; 49 | } 50 | 51 | .irs-slider { 52 | position: absolute; display: block; 53 | cursor: default; 54 | z-index: 1; 55 | } 56 | .irs-slider.single { 57 | 58 | } 59 | .irs-slider.from { 60 | 61 | } 62 | .irs-slider.to { 63 | 64 | } 65 | .irs-slider.type_last { 66 | z-index: 2; 67 | } 68 | 69 | .irs-min { 70 | position: absolute; display: block; 71 | left: 0; 72 | cursor: default; 73 | } 74 | .irs-max { 75 | position: absolute; display: block; 76 | right: 0; 77 | cursor: default; 78 | } 79 | 80 | .irs-from, .irs-to, .irs-single { 81 | position: absolute; display: block; 82 | top: 0; left: 0; 83 | cursor: default; 84 | white-space: nowrap; 85 | } 86 | 87 | .irs-grid { 88 | position: absolute; display: none; 89 | bottom: 0; left: 0; 90 | width: 100%; height: 20px; 91 | } 92 | .irs-with-grid .irs-grid { 93 | display: block; 94 | } 95 | .irs-grid-pol { 96 | position: absolute; 97 | top: 0; left: 0; 98 | width: 1px; height: 8px; 99 | background: #000; 100 | } 101 | .irs-grid-pol.small { 102 | height: 4px; 103 | } 104 | .irs-grid-text { 105 | position: absolute; 106 | bottom: 0; left: 0; 107 | white-space: nowrap; 108 | text-align: center; 109 | font-size: 9px; line-height: 9px; 110 | padding: 0 3px; 111 | color: #000; 112 | } 113 | 114 | .irs-disable-mask { 115 | position: absolute; display: block; 116 | top: 0; left: -1%; 117 | width: 102%; height: 100%; 118 | cursor: default; 119 | background: rgba(0,0,0,0.0); 120 | z-index: 2; 121 | } 122 | .lt-ie9 .irs-disable-mask { 123 | background: #000; 124 | filter: alpha(opacity=0); 125 | cursor: not-allowed; 126 | } 127 | 128 | .irs-disabled { 129 | opacity: 0.4; 130 | } 131 | 132 | 133 | .irs-hidden-input { 134 | position: absolute !important; 135 | display: block !important; 136 | top: 0 !important; 137 | left: 0 !important; 138 | width: 0 !important; 139 | height: 0 !important; 140 | font-size: 0 !important; 141 | line-height: 0 !important; 142 | padding: 0 !important; 143 | margin: 0 !important; 144 | overflow: hidden; 145 | outline: none !important; 146 | z-index: -9999 !important; 147 | background: none !important; 148 | border-style: solid !important; 149 | border-color: transparent !important; 150 | } 151 | 152 | /* Ion.RangeSlider, Nice Skin 153 | // css version 2.0.3 154 | // © Denis Ineshin, 2014 https://github.com/IonDen 155 | // ===================================================================================================================*/ 156 | 157 | /* ===================================================================================================================== 158 | // Skin details */ 159 | 160 | .irs-line-mid, 161 | .irs-line-left, 162 | .irs-line-right, 163 | .irs-bar, 164 | .irs-bar-edge, 165 | .irs-slider { 166 | background: url(sprite-skin-nice.png) repeat-x; 167 | } 168 | 169 | .irs { 170 | height: 40px; 171 | } 172 | .irs-with-grid { 173 | height: 60px; 174 | } 175 | .irs-line { 176 | height: 8px; top: 25px; 177 | } 178 | .irs-line-left { 179 | height: 8px; 180 | background-position: 0 -30px; 181 | } 182 | .irs-line-mid { 183 | height: 8px; 184 | background-position: 0 0; 185 | } 186 | .irs-line-right { 187 | height: 8px; 188 | background-position: 100% -30px; 189 | } 190 | 191 | .irs-bar { 192 | height: 8px; top: 25px; 193 | background-position: 0 -60px; 194 | } 195 | .irs-bar-edge { 196 | top: 25px; 197 | height: 8px; width: 11px; 198 | background-position: 0 -90px; 199 | } 200 | 201 | .irs-shadow { 202 | height: 1px; top: 34px; 203 | background: #000; 204 | opacity: 0.15; 205 | } 206 | .lt-ie9 .irs-shadow { 207 | filter: alpha(opacity=15); 208 | } 209 | 210 | .irs-slider { 211 | width: 22px; height: 22px; 212 | top: 17px; 213 | background-position: 0 -120px; 214 | } 215 | .irs-slider.state_hover, .irs-slider:hover { 216 | background-position: 0 -150px; 217 | } 218 | 219 | .irs-min, .irs-max { 220 | color: #999; 221 | font-size: 10px; line-height: 1.333; 222 | text-shadow: none; 223 | top: 0; padding: 1px 3px; 224 | background: rgba(0,0,0,0.1); 225 | -moz-border-radius: 3px; 226 | border-radius: 3px; 227 | } 228 | .lt-ie9 .irs-min, .lt-ie9 .irs-max { 229 | background: #ccc; 230 | } 231 | 232 | .irs-from, .irs-to, .irs-single { 233 | color: #fff; 234 | font-size: 10px; line-height: 1.333; 235 | text-shadow: none; 236 | padding: 1px 5px; 237 | background: rgba(0,0,0,0.3); 238 | -moz-border-radius: 3px; 239 | border-radius: 3px; 240 | } 241 | .lt-ie9 .irs-from, .lt-ie9 .irs-to, .lt-ie9 .irs-single { 242 | background: #999; 243 | } 244 | 245 | .irs-grid-pol { 246 | background: #99a4ac; 247 | } 248 | .irs-grid-text { 249 | color: #99a4ac; 250 | } 251 | 252 | .irs-disabled { 253 | } 254 | -------------------------------------------------------------------------------- /frontend/css/nfsen-ng.css: -------------------------------------------------------------------------------- 1 | /* general */ 2 | @media (prefers-color-scheme: light) { html { filter: invert(0.0); } :root { } } 3 | @media (prefers-color-scheme: dark) { html { filter: invert(0.85); } :root { } } 4 | html { filter: none; } 5 | 6 | :root { 7 | --bs-primary: #ccc; 8 | } 9 | 10 | .nav { 11 | --bs-nav-link-color: #000; 12 | --bs-link-color-rgb: 0,0,0; 13 | } 14 | 15 | .btn-outline-primary { 16 | --bs-btn-bg: #fff; 17 | --bs-btn-color: #000; 18 | --bs-btn-border-color: #ccc; 19 | --bs-btn-hover-bg: #ccc; 20 | --bs-btn-hover-border-color: #ccc; 21 | --bs-btn-active-bg: #ccc; 22 | --bs-btn-active-border-color: #ccc; 23 | --bs-btn-active-color: #000; 24 | --bs-gradient: linear-gradient(#000 0%, #fff 100%); 25 | --bs-btn-disabled-color: #000; 26 | --bs-btn-disabled-border-color: #000; 27 | } 28 | 29 | /* light grey background for active nav links */ 30 | .nav-tabs .nav-link.active { 31 | --bs-nav-tabs-link-active-bg: rgba(var(--bs-light-rgb),1); 32 | border-bottom: 1px solid rgb(var(--bs-light-rgb)); 33 | font-weight: bold; 34 | } 35 | 36 | .btn.disabled, .btn:disabled, fieldset:disabled .btn { 37 | opacity: .3; 38 | } 39 | 40 | textarea { 41 | resize: none; 42 | } 43 | 44 | code, pre, .code { 45 | font-family: Menlo, Monaco, Consolas, "Courier New", monospace 46 | } 47 | 48 | .h5, .h6 { 49 | text-transform: uppercase; 50 | font-weight: bold; 51 | } 52 | 53 | /* graph */ 54 | 55 | #flowDiv .dygraph-title { 56 | font-size: 1.5rem; 57 | text-transform: uppercase; 58 | } 59 | 60 | #flowDiv .dygraph-ylabel { 61 | font-size: 1rem; 62 | } 63 | 64 | #legend span { 65 | padding: .2rem; 66 | } 67 | 68 | #legend span.highlight { 69 | background-color: #eee; 70 | } 71 | 72 | #series label { 73 | display: block; 74 | } 75 | 76 | #viewList li h4 { 77 | margin-left: 10px; 78 | } 79 | 80 | /* graph options */ 81 | 82 | .accordion h4:after { 83 | content: "\25B2"; 84 | float: right; 85 | } 86 | 87 | .accordion h4.collapsed:after { 88 | content: "\25BC"; 89 | } 90 | 91 | /* flows */ 92 | #sourceCIDRPrefixDiv:before, #destinationCIDRPrefixDiv:before { 93 | content: "/"; 94 | width: auto; 95 | position: absolute; 96 | font-size: 170%; 97 | padding-left: 1rem; 98 | } 99 | #sourceCIDRPrefix, #destinationCIDRPrefix { padding-left: 2rem; } 100 | -------------------------------------------------------------------------------- /frontend/css/sprite-skin-nice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mbolli/nfsen-ng/fd949e50942f48aa0b664eddeefdb45f6959a1ec/frontend/css/sprite-skin-nice.png -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | nfsen-ng 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | nfsen-ng 25 | 26 | 57 |
58 |
59 |
60 |
61 |
62 |
63 | 64 |
65 | 66 |
67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 |
79 | 80 |
81 | 84 | 87 |
88 | 89 |
90 | 94 |
95 |
96 |
97 | 98 |
99 |
    100 |
  • 101 | Config /api/config 102 |
  • 103 |
  • 104 | Theme 105 | Light 106 | Dark 107 |
  • 108 | 109 | 119 |
120 | 121 |
122 |

Display

123 | 124 |
125 | 130 |
131 |
132 | 133 |
134 |

Ports

135 | 136 |
137 | 138 |
139 |
140 | 141 |
142 |

Sources

143 | 144 |
145 | 148 |
149 |
150 | 151 |
152 |

Protocols

153 | 154 |
155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 |
166 |
167 | 168 |
169 |

Data Type

170 | 171 |
172 | 173 | 174 | 175 | 176 | 177 | 178 |
179 |
180 | 181 |
182 |

Traffic unit

183 | 184 |
185 | 186 | 187 | 188 | 189 |
190 |
191 | 192 |
193 |

Limit Flows

194 | 195 |
196 | 204 |
205 |
206 | 207 |
208 |

Top records

209 |
210 | 218 |
219 |
220 | 221 | 222 |
223 | 224 |

NFDUMP filter

227 | 228 |
229 | 230 |
231 | 232 |
233 |
234 | 235 | 238 |
239 | 240 | 241 | 242 |
243 | 244 |
245 | 246 |
247 |
248 | 249 |

Global Aggregation

250 |
251 | 252 | 253 | 254 | 255 | 256 |
257 | 258 |
259 |
260 |

Port Aggregation

261 |
262 | 263 | 264 | 265 | 266 | 267 |
268 |
269 | 270 |
271 |

IP Aggregation

272 |
273 |
274 |
275 |
276 | 282 |
283 | 284 |
285 |
286 |
287 | 288 |
289 | 290 |
291 | 297 |
298 | 299 |
300 |
301 |
302 |
303 |
304 |
305 | 306 |
307 |

Data Limit

308 |
309 | 314 |
315 |
316 | 317 |
318 | 319 |

Statistic properties

320 | 321 |
322 | 323 | 370 |
371 | 372 |
373 | 374 | 382 |
383 | 384 |
385 | 386 | 421 | 422 | 430 |
431 |
432 | 433 |
434 | 435 |
436 | 437 |
438 | 439 |
440 | 441 |
442 |
443 |
444 | 445 |
446 |
447 |

Graph Scale

448 |
449 | 450 | 451 | 452 | 453 | 454 |
455 |
456 | 457 |
458 |

Series display

459 |
460 | 461 | 462 | 463 | 464 | 465 |
466 | 467 |
468 | 469 | 470 | 471 | 472 | 473 |
474 |
475 | 476 |
477 |

Series

478 |
479 |
480 | 481 |
482 |

Values

483 |
484 |
485 |
486 |
487 |
488 |
489 |
490 | 491 |
492 |
493 |
494 | 495 |
496 | 497 | 516 | 517 | 518 | 519 | -------------------------------------------------------------------------------- /frontend/js/popper.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @popperjs/core v2.11.8 - MIT License 3 | */ 4 | 5 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Popper={})}(this,(function(e){"use strict";function t(e){if(null==e)return window;if("[object Window]"!==e.toString()){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function n(e){return e instanceof t(e).Element||e instanceof Element}function r(e){return e instanceof t(e).HTMLElement||e instanceof HTMLElement}function o(e){return"undefined"!=typeof ShadowRoot&&(e instanceof t(e).ShadowRoot||e instanceof ShadowRoot)}var i=Math.max,a=Math.min,s=Math.round;function f(){var e=navigator.userAgentData;return null!=e&&e.brands&&Array.isArray(e.brands)?e.brands.map((function(e){return e.brand+"/"+e.version})).join(" "):navigator.userAgent}function c(){return!/^((?!chrome|android).)*safari/i.test(f())}function p(e,o,i){void 0===o&&(o=!1),void 0===i&&(i=!1);var a=e.getBoundingClientRect(),f=1,p=1;o&&r(e)&&(f=e.offsetWidth>0&&s(a.width)/e.offsetWidth||1,p=e.offsetHeight>0&&s(a.height)/e.offsetHeight||1);var u=(n(e)?t(e):window).visualViewport,l=!c()&&i,d=(a.left+(l&&u?u.offsetLeft:0))/f,h=(a.top+(l&&u?u.offsetTop:0))/p,m=a.width/f,v=a.height/p;return{width:m,height:v,top:h,right:d+m,bottom:h+v,left:d,x:d,y:h}}function u(e){var n=t(e);return{scrollLeft:n.pageXOffset,scrollTop:n.pageYOffset}}function l(e){return e?(e.nodeName||"").toLowerCase():null}function d(e){return((n(e)?e.ownerDocument:e.document)||window.document).documentElement}function h(e){return p(d(e)).left+u(e).scrollLeft}function m(e){return t(e).getComputedStyle(e)}function v(e){var t=m(e),n=t.overflow,r=t.overflowX,o=t.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+r)}function y(e,n,o){void 0===o&&(o=!1);var i,a,f=r(n),c=r(n)&&function(e){var t=e.getBoundingClientRect(),n=s(t.width)/e.offsetWidth||1,r=s(t.height)/e.offsetHeight||1;return 1!==n||1!==r}(n),m=d(n),y=p(e,c,o),g={scrollLeft:0,scrollTop:0},b={x:0,y:0};return(f||!f&&!o)&&(("body"!==l(n)||v(m))&&(g=(i=n)!==t(i)&&r(i)?{scrollLeft:(a=i).scrollLeft,scrollTop:a.scrollTop}:u(i)),r(n)?((b=p(n,!0)).x+=n.clientLeft,b.y+=n.clientTop):m&&(b.x=h(m))),{x:y.left+g.scrollLeft-b.x,y:y.top+g.scrollTop-b.y,width:y.width,height:y.height}}function g(e){var t=p(e),n=e.offsetWidth,r=e.offsetHeight;return Math.abs(t.width-n)<=1&&(n=t.width),Math.abs(t.height-r)<=1&&(r=t.height),{x:e.offsetLeft,y:e.offsetTop,width:n,height:r}}function b(e){return"html"===l(e)?e:e.assignedSlot||e.parentNode||(o(e)?e.host:null)||d(e)}function x(e){return["html","body","#document"].indexOf(l(e))>=0?e.ownerDocument.body:r(e)&&v(e)?e:x(b(e))}function w(e,n){var r;void 0===n&&(n=[]);var o=x(e),i=o===(null==(r=e.ownerDocument)?void 0:r.body),a=t(o),s=i?[a].concat(a.visualViewport||[],v(o)?o:[]):o,f=n.concat(s);return i?f:f.concat(w(b(s)))}function O(e){return["table","td","th"].indexOf(l(e))>=0}function j(e){return r(e)&&"fixed"!==m(e).position?e.offsetParent:null}function E(e){for(var n=t(e),i=j(e);i&&O(i)&&"static"===m(i).position;)i=j(i);return i&&("html"===l(i)||"body"===l(i)&&"static"===m(i).position)?n:i||function(e){var t=/firefox/i.test(f());if(/Trident/i.test(f())&&r(e)&&"fixed"===m(e).position)return null;var n=b(e);for(o(n)&&(n=n.host);r(n)&&["html","body"].indexOf(l(n))<0;){var i=m(n);if("none"!==i.transform||"none"!==i.perspective||"paint"===i.contain||-1!==["transform","perspective"].indexOf(i.willChange)||t&&"filter"===i.willChange||t&&i.filter&&"none"!==i.filter)return n;n=n.parentNode}return null}(e)||n}var D="top",A="bottom",L="right",P="left",M="auto",k=[D,A,L,P],W="start",B="end",H="viewport",T="popper",R=k.reduce((function(e,t){return e.concat([t+"-"+W,t+"-"+B])}),[]),S=[].concat(k,[M]).reduce((function(e,t){return e.concat([t,t+"-"+W,t+"-"+B])}),[]),V=["beforeRead","read","afterRead","beforeMain","main","afterMain","beforeWrite","write","afterWrite"];function q(e){var t=new Map,n=new Set,r=[];function o(e){n.add(e.name),[].concat(e.requires||[],e.requiresIfExists||[]).forEach((function(e){if(!n.has(e)){var r=t.get(e);r&&o(r)}})),r.push(e)}return e.forEach((function(e){t.set(e.name,e)})),e.forEach((function(e){n.has(e.name)||o(e)})),r}function C(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&o(n)){var r=t;do{if(r&&e.isSameNode(r))return!0;r=r.parentNode||r.host}while(r)}return!1}function N(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function I(e,r,o){return r===H?N(function(e,n){var r=t(e),o=d(e),i=r.visualViewport,a=o.clientWidth,s=o.clientHeight,f=0,p=0;if(i){a=i.width,s=i.height;var u=c();(u||!u&&"fixed"===n)&&(f=i.offsetLeft,p=i.offsetTop)}return{width:a,height:s,x:f+h(e),y:p}}(e,o)):n(r)?function(e,t){var n=p(e,!1,"fixed"===t);return n.top=n.top+e.clientTop,n.left=n.left+e.clientLeft,n.bottom=n.top+e.clientHeight,n.right=n.left+e.clientWidth,n.width=e.clientWidth,n.height=e.clientHeight,n.x=n.left,n.y=n.top,n}(r,o):N(function(e){var t,n=d(e),r=u(e),o=null==(t=e.ownerDocument)?void 0:t.body,a=i(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),s=i(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),f=-r.scrollLeft+h(e),c=-r.scrollTop;return"rtl"===m(o||n).direction&&(f+=i(n.clientWidth,o?o.clientWidth:0)-a),{width:a,height:s,x:f,y:c}}(d(e)))}function _(e,t,o,s){var f="clippingParents"===t?function(e){var t=w(b(e)),o=["absolute","fixed"].indexOf(m(e).position)>=0&&r(e)?E(e):e;return n(o)?t.filter((function(e){return n(e)&&C(e,o)&&"body"!==l(e)})):[]}(e):[].concat(t),c=[].concat(f,[o]),p=c[0],u=c.reduce((function(t,n){var r=I(e,n,s);return t.top=i(r.top,t.top),t.right=a(r.right,t.right),t.bottom=a(r.bottom,t.bottom),t.left=i(r.left,t.left),t}),I(e,p,s));return u.width=u.right-u.left,u.height=u.bottom-u.top,u.x=u.left,u.y=u.top,u}function F(e){return e.split("-")[0]}function U(e){return e.split("-")[1]}function z(e){return["top","bottom"].indexOf(e)>=0?"x":"y"}function X(e){var t,n=e.reference,r=e.element,o=e.placement,i=o?F(o):null,a=o?U(o):null,s=n.x+n.width/2-r.width/2,f=n.y+n.height/2-r.height/2;switch(i){case D:t={x:s,y:n.y-r.height};break;case A:t={x:s,y:n.y+n.height};break;case L:t={x:n.x+n.width,y:f};break;case P:t={x:n.x-r.width,y:f};break;default:t={x:n.x,y:n.y}}var c=i?z(i):null;if(null!=c){var p="y"===c?"height":"width";switch(a){case W:t[c]=t[c]-(n[p]/2-r[p]/2);break;case B:t[c]=t[c]+(n[p]/2-r[p]/2)}}return t}function Y(e){return Object.assign({},{top:0,right:0,bottom:0,left:0},e)}function G(e,t){return t.reduce((function(t,n){return t[n]=e,t}),{})}function J(e,t){void 0===t&&(t={});var r=t,o=r.placement,i=void 0===o?e.placement:o,a=r.strategy,s=void 0===a?e.strategy:a,f=r.boundary,c=void 0===f?"clippingParents":f,u=r.rootBoundary,l=void 0===u?H:u,h=r.elementContext,m=void 0===h?T:h,v=r.altBoundary,y=void 0!==v&&v,g=r.padding,b=void 0===g?0:g,x=Y("number"!=typeof b?b:G(b,k)),w=m===T?"reference":T,O=e.rects.popper,j=e.elements[y?w:m],E=_(n(j)?j:j.contextElement||d(e.elements.popper),c,l,s),P=p(e.elements.reference),M=X({reference:P,element:O,strategy:"absolute",placement:i}),W=N(Object.assign({},O,M)),B=m===T?W:P,R={top:E.top-B.top+x.top,bottom:B.bottom-E.bottom+x.bottom,left:E.left-B.left+x.left,right:B.right-E.right+x.right},S=e.modifiersData.offset;if(m===T&&S){var V=S[i];Object.keys(R).forEach((function(e){var t=[L,A].indexOf(e)>=0?1:-1,n=[D,A].indexOf(e)>=0?"y":"x";R[e]+=V[n]*t}))}return R}var K={placement:"bottom",modifiers:[],strategy:"absolute"};function Q(){for(var e=arguments.length,t=new Array(e),n=0;n=0?-1:1,i="function"==typeof n?n(Object.assign({},t,{placement:e})):n,a=i[0],s=i[1];return a=a||0,s=(s||0)*o,[P,L].indexOf(r)>=0?{x:s,y:a}:{x:a,y:s}}(n,t.rects,i),e}),{}),s=a[t.placement],f=s.x,c=s.y;null!=t.modifiersData.popperOffsets&&(t.modifiersData.popperOffsets.x+=f,t.modifiersData.popperOffsets.y+=c),t.modifiersData[r]=a}},se={left:"right",right:"left",bottom:"top",top:"bottom"};function fe(e){return e.replace(/left|right|bottom|top/g,(function(e){return se[e]}))}var ce={start:"end",end:"start"};function pe(e){return e.replace(/start|end/g,(function(e){return ce[e]}))}function ue(e,t){void 0===t&&(t={});var n=t,r=n.placement,o=n.boundary,i=n.rootBoundary,a=n.padding,s=n.flipVariations,f=n.allowedAutoPlacements,c=void 0===f?S:f,p=U(r),u=p?s?R:R.filter((function(e){return U(e)===p})):k,l=u.filter((function(e){return c.indexOf(e)>=0}));0===l.length&&(l=u);var d=l.reduce((function(t,n){return t[n]=J(e,{placement:n,boundary:o,rootBoundary:i,padding:a})[F(n)],t}),{});return Object.keys(d).sort((function(e,t){return d[e]-d[t]}))}var le={name:"flip",enabled:!0,phase:"main",fn:function(e){var t=e.state,n=e.options,r=e.name;if(!t.modifiersData[r]._skip){for(var o=n.mainAxis,i=void 0===o||o,a=n.altAxis,s=void 0===a||a,f=n.fallbackPlacements,c=n.padding,p=n.boundary,u=n.rootBoundary,l=n.altBoundary,d=n.flipVariations,h=void 0===d||d,m=n.allowedAutoPlacements,v=t.options.placement,y=F(v),g=f||(y===v||!h?[fe(v)]:function(e){if(F(e)===M)return[];var t=fe(e);return[pe(e),t,pe(t)]}(v)),b=[v].concat(g).reduce((function(e,n){return e.concat(F(n)===M?ue(t,{placement:n,boundary:p,rootBoundary:u,padding:c,flipVariations:h,allowedAutoPlacements:m}):n)}),[]),x=t.rects.reference,w=t.rects.popper,O=new Map,j=!0,E=b[0],k=0;k=0,S=R?"width":"height",V=J(t,{placement:B,boundary:p,rootBoundary:u,altBoundary:l,padding:c}),q=R?T?L:P:T?A:D;x[S]>w[S]&&(q=fe(q));var C=fe(q),N=[];if(i&&N.push(V[H]<=0),s&&N.push(V[q]<=0,V[C]<=0),N.every((function(e){return e}))){E=B,j=!1;break}O.set(B,N)}if(j)for(var I=function(e){var t=b.find((function(t){var n=O.get(t);if(n)return n.slice(0,e).every((function(e){return e}))}));if(t)return E=t,"break"},_=h?3:1;_>0;_--){if("break"===I(_))break}t.placement!==E&&(t.modifiersData[r]._skip=!0,t.placement=E,t.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function de(e,t,n){return i(e,a(t,n))}var he={name:"preventOverflow",enabled:!0,phase:"main",fn:function(e){var t=e.state,n=e.options,r=e.name,o=n.mainAxis,s=void 0===o||o,f=n.altAxis,c=void 0!==f&&f,p=n.boundary,u=n.rootBoundary,l=n.altBoundary,d=n.padding,h=n.tether,m=void 0===h||h,v=n.tetherOffset,y=void 0===v?0:v,b=J(t,{boundary:p,rootBoundary:u,padding:d,altBoundary:l}),x=F(t.placement),w=U(t.placement),O=!w,j=z(x),M="x"===j?"y":"x",k=t.modifiersData.popperOffsets,B=t.rects.reference,H=t.rects.popper,T="function"==typeof y?y(Object.assign({},t.rects,{placement:t.placement})):y,R="number"==typeof T?{mainAxis:T,altAxis:T}:Object.assign({mainAxis:0,altAxis:0},T),S=t.modifiersData.offset?t.modifiersData.offset[t.placement]:null,V={x:0,y:0};if(k){if(s){var q,C="y"===j?D:P,N="y"===j?A:L,I="y"===j?"height":"width",_=k[j],X=_+b[C],Y=_-b[N],G=m?-H[I]/2:0,K=w===W?B[I]:H[I],Q=w===W?-H[I]:-B[I],Z=t.elements.arrow,$=m&&Z?g(Z):{width:0,height:0},ee=t.modifiersData["arrow#persistent"]?t.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},te=ee[C],ne=ee[N],re=de(0,B[I],$[I]),oe=O?B[I]/2-G-re-te-R.mainAxis:K-re-te-R.mainAxis,ie=O?-B[I]/2+G+re+ne+R.mainAxis:Q+re+ne+R.mainAxis,ae=t.elements.arrow&&E(t.elements.arrow),se=ae?"y"===j?ae.clientTop||0:ae.clientLeft||0:0,fe=null!=(q=null==S?void 0:S[j])?q:0,ce=_+ie-fe,pe=de(m?a(X,_+oe-fe-se):X,_,m?i(Y,ce):Y);k[j]=pe,V[j]=pe-_}if(c){var ue,le="x"===j?D:P,he="x"===j?A:L,me=k[M],ve="y"===M?"height":"width",ye=me+b[le],ge=me-b[he],be=-1!==[D,P].indexOf(x),xe=null!=(ue=null==S?void 0:S[M])?ue:0,we=be?ye:me-B[ve]-H[ve]-xe+R.altAxis,Oe=be?me+B[ve]+H[ve]-xe-R.altAxis:ge,je=m&&be?function(e,t,n){var r=de(e,t,n);return r>n?n:r}(we,me,Oe):de(m?we:ye,me,m?Oe:ge);k[M]=je,V[M]=je-me}t.modifiersData[r]=V}},requiresIfExists:["offset"]};var me={name:"arrow",enabled:!0,phase:"main",fn:function(e){var t,n=e.state,r=e.name,o=e.options,i=n.elements.arrow,a=n.modifiersData.popperOffsets,s=F(n.placement),f=z(s),c=[P,L].indexOf(s)>=0?"height":"width";if(i&&a){var p=function(e,t){return Y("number"!=typeof(e="function"==typeof e?e(Object.assign({},t.rects,{placement:t.placement})):e)?e:G(e,k))}(o.padding,n),u=g(i),l="y"===f?D:P,d="y"===f?A:L,h=n.rects.reference[c]+n.rects.reference[f]-a[f]-n.rects.popper[c],m=a[f]-n.rects.reference[f],v=E(i),y=v?"y"===f?v.clientHeight||0:v.clientWidth||0:0,b=h/2-m/2,x=p[l],w=y-u[c]-p[d],O=y/2-u[c]/2+b,j=de(x,O,w),M=f;n.modifiersData[r]=((t={})[M]=j,t.centerOffset=j-O,t)}},effect:function(e){var t=e.state,n=e.options.element,r=void 0===n?"[data-popper-arrow]":n;null!=r&&("string"!=typeof r||(r=t.elements.popper.querySelector(r)))&&C(t.elements.popper,r)&&(t.elements.arrow=r)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function ve(e,t,n){return void 0===n&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function ye(e){return[D,L,A,P].some((function(t){return e[t]>=0}))}var ge={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(e){var t=e.state,n=e.name,r=t.rects.reference,o=t.rects.popper,i=t.modifiersData.preventOverflow,a=J(t,{elementContext:"reference"}),s=J(t,{altBoundary:!0}),f=ve(a,r),c=ve(s,o,i),p=ye(f),u=ye(c);t.modifiersData[n]={referenceClippingOffsets:f,popperEscapeOffsets:c,isReferenceHidden:p,hasPopperEscaped:u},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":p,"data-popper-escaped":u})}},be=Z({defaultModifiers:[ee,te,oe,ie]}),xe=[ee,te,oe,ie,ae,le,he,me,ge],we=Z({defaultModifiers:xe});e.applyStyles=ie,e.arrow=me,e.computeStyles=oe,e.createPopper=we,e.createPopperLite=be,e.defaultModifiers=xe,e.detectOverflow=J,e.eventListeners=ee,e.flip=le,e.hide=ge,e.offset=ae,e.popperGenerator=Z,e.popperOffsets=te,e.preventOverflow=he,Object.defineProperty(e,"__esModule",{value:!0})})); 6 | //# sourceMappingURL=popper.min.js.map 7 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | --------------------------------------------------------------------------------