diff --git a/client/package-lock.json b/client/package-lock.json index 90c4599c033..02715ad620f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -190,7 +190,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -214,7 +213,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -307,7 +305,6 @@ "version": "7.24.5", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.5.tgz", "integrity": "sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA==", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.2", @@ -1980,7 +1977,6 @@ "version": "7.24.5", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.5.tgz", "integrity": "sha512-UGK2ifKtcC8i5AI4cH+sbLLuLc2ktYSFJgBAXorKAsHUZmrQ1q6aQ6i3BvU24wWs2AAKqQB6kq3N9V9Gw1HiMQ==", - "peer": true, "dependencies": { "@babel/compat-data": "^7.24.4", "@babel/helper-compilation-targets": "^7.23.6", @@ -2268,7 +2264,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -2314,7 +2309,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": "^14 || ^16 || >=18" } @@ -3123,7 +3117,6 @@ "version": "0.64.0", "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.64.0.tgz", "integrity": "sha512-tupETbvxgv4B9y3pcXy/lErMwY2aZht+FKSyah1dPFd88LnMD/DOL+to6ociBHmpLQNUMA7wid6R7BlXRY/bmg==", - "peer": true, "dependencies": { "d3-color": "^1.2.3", "d3-format": "^1.4.4", @@ -3196,7 +3189,6 @@ "version": "0.64.0", "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.64.0.tgz", "integrity": "sha512-iGsuCi42uw/8F7OVvPyWdQgxJXVOPTEdtl2WK2FlSJIH7bfnEsZ+R/lTdElY2JAvGHuNW6hQwpNUZdC/2rOatg==", - "peer": true, "dependencies": { "react-spring": "^8.0.27" }, @@ -3654,7 +3646,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3775,7 +3766,8 @@ "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/lodash": { "version": "4.17.4", @@ -4076,7 +4068,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.10.0.tgz", "integrity": "sha512-2EjZMA0LUW5V5tGQiaa2Gys+nKdfrn2xiTIBLR4fxmPmVSvgPcKNW+AE/ln9k0A4zDUti0J/GZXMDupQoI+e1w==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.10.0", "@typescript-eslint/types": "7.10.0", @@ -4833,7 +4824,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4876,7 +4866,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5125,6 +5114,7 @@ "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", "dev": true, + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -5702,7 +5692,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -7539,7 +7528,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7644,7 +7632,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7657,6 +7644,7 @@ "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, + "peer": true, "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", @@ -7668,6 +7656,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "peer": true, "dependencies": { "ms": "^2.1.1" } @@ -7676,13 +7665,15 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/eslint-module-utils": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", "dev": true, + "peer": true, "dependencies": { "debug": "^3.2.7" }, @@ -7700,6 +7691,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "peer": true, "dependencies": { "ms": "^2.1.1" } @@ -7708,13 +7700,15 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/eslint-plugin-import": { "version": "2.29.1", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, + "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -7746,6 +7740,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "peer": true, "dependencies": { "ms": "^2.1.1" } @@ -7755,6 +7750,7 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, + "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -7766,13 +7762,15 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/eslint-plugin-import/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -7782,7 +7780,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.8.0.tgz", "integrity": "sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==", "dev": true, - "peer": true, "dependencies": { "@babel/runtime": "^7.23.2", "aria-query": "^5.3.0", @@ -7849,7 +7846,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.1.tgz", "integrity": "sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw==", "dev": true, - "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlast": "^1.2.4", @@ -7882,7 +7878,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -9734,7 +9729,6 @@ "version": "19.6.2", "resolved": "https://registry.npmjs.org/i18next/-/i18next-19.6.2.tgz", "integrity": "sha512-Zyd/Z32FY+sD+Eg6sLj5DeDSlrIN3WZ4onuOBRGcjDx/rvodsyUZ9TJ2Y+3aD9Vu8MPbiMU2WesIER/rs1ioyw==", - "peer": true, "dependencies": { "@babel/runtime": "^7.10.1" } @@ -10652,7 +10646,6 @@ "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.5.4", "cssstyle": "^5.3.0", @@ -11454,8 +11447,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/memoize-one": { "version": "5.1.1", @@ -11619,6 +11611,7 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11944,6 +11937,7 @@ "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", "dev": true, + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -12502,7 +12496,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -12695,7 +12688,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz", "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==", "dev": true, - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12723,7 +12715,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "dev": true, - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12813,7 +12804,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -12946,7 +12936,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz", "integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -12968,7 +12957,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz", "integrity": "sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -13226,7 +13214,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz", "integrity": "sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==", - "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "history": "^4.9.0", @@ -13422,7 +13409,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "symbol-observable": "^1.2.0" @@ -13937,7 +13923,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", @@ -14790,6 +14775,7 @@ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, + "peer": true, "engines": { "node": ">=4" } @@ -15327,7 +15313,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16183,6 +16168,7 @@ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, + "peer": true, "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", @@ -16195,6 +16181,7 @@ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, + "peer": true, "dependencies": { "minimist": "^1.2.0" }, @@ -16665,7 +16652,6 @@ "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -16782,7 +16768,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16955,7 +16940,6 @@ "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -17004,7 +16988,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -17473,7 +17456,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -17539,15 +17521,13 @@ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, - "peer": true, "requires": {} }, "@csstools/css-tokenizer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "peer": true + "dev": true }, "lru-cache": { "version": "11.2.2", @@ -17619,7 +17599,6 @@ "version": "7.24.5", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.5.tgz", "integrity": "sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA==", - "peer": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.2", @@ -18695,7 +18674,6 @@ "version": "7.24.5", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.5.tgz", "integrity": "sha512-UGK2ifKtcC8i5AI4cH+sbLLuLc2ktYSFJgBAXorKAsHUZmrQ1q6aQ6i3BvU24wWs2AAKqQB6kq3N9V9Gw1HiMQ==", - "peer": true, "requires": { "@babel/compat-data": "^7.24.4", "@babel/helper-compilation-targets": "^7.23.6", @@ -18909,7 +18887,6 @@ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.6.3.tgz", "integrity": "sha512-xI/tL2zxzEbESvnSxwFgwvy5HS00oCXxL4MLs6HUiDcYfwowsoQaABKxUElp1ARITrINzBnsECOc1q0eg2GOrA==", "dev": true, - "peer": true, "requires": {} }, "@csstools/css-syntax-patches-for-csstree": { @@ -18923,8 +18900,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.3.1.tgz", "integrity": "sha512-iMNHTyxLbBlWIfGtabT157LH9DUx9X8+Y3oymFEuMj8HNc+rpE3dPFGFgHjpKfjeFDjLjYIAIhXPGvS2lKxL9g==", - "dev": true, - "peer": true + "dev": true }, "@csstools/media-query-list-parser": { "version": "2.1.11", @@ -19383,7 +19359,6 @@ "version": "0.64.0", "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.64.0.tgz", "integrity": "sha512-tupETbvxgv4B9y3pcXy/lErMwY2aZht+FKSyah1dPFd88LnMD/DOL+to6ociBHmpLQNUMA7wid6R7BlXRY/bmg==", - "peer": true, "requires": { "d3-color": "^1.2.3", "d3-format": "^1.4.4", @@ -19439,7 +19414,6 @@ "version": "0.64.0", "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.64.0.tgz", "integrity": "sha512-iGsuCi42uw/8F7OVvPyWdQgxJXVOPTEdtl2WK2FlSJIH7bfnEsZ+R/lTdElY2JAvGHuNW6hQwpNUZdC/2rOatg==", - "peer": true, "requires": { "react-spring": "^8.0.27" } @@ -19719,7 +19693,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==", "dev": true, - "peer": true, "requires": { "@types/estree": "*", "@types/json-schema": "*" @@ -19840,7 +19813,8 @@ "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true + "dev": true, + "peer": true }, "@types/lodash": { "version": "4.17.4", @@ -20104,7 +20078,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.10.0.tgz", "integrity": "sha512-2EjZMA0LUW5V5tGQiaa2Gys+nKdfrn2xiTIBLR4fxmPmVSvgPcKNW+AE/ln9k0A4zDUti0J/GZXMDupQoI+e1w==", "dev": true, - "peer": true, "requires": { "@typescript-eslint/scope-manager": "7.10.0", "@typescript-eslint/types": "7.10.0", @@ -20611,8 +20584,7 @@ "acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "peer": true + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==" }, "acorn-import-phases": { "version": "1.0.4", @@ -20637,7 +20609,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -20815,6 +20786,7 @@ "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", "dev": true, + "peer": true, "requires": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -21243,7 +21215,6 @@ "version": "4.26.3", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", - "peer": true, "requires": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -22616,7 +22587,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -22852,7 +22822,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, - "peer": true, "requires": {} }, "eslint-import-resolver-node": { @@ -22860,6 +22829,7 @@ "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, + "peer": true, "requires": { "debug": "^3.2.7", "is-core-module": "^2.13.0", @@ -22871,6 +22841,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "peer": true, "requires": { "ms": "^2.1.1" } @@ -22879,7 +22850,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "peer": true } } }, @@ -22888,6 +22860,7 @@ "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", "dev": true, + "peer": true, "requires": { "debug": "^3.2.7" }, @@ -22897,6 +22870,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "peer": true, "requires": { "ms": "^2.1.1" } @@ -22905,7 +22879,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "peer": true } } }, @@ -22914,6 +22889,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, + "peer": true, "requires": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -22939,6 +22915,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "peer": true, "requires": { "ms": "^2.1.1" } @@ -22948,6 +22925,7 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, + "peer": true, "requires": { "esutils": "^2.0.2" } @@ -22956,13 +22934,15 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "peer": true }, "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true + "dev": true, + "peer": true } } }, @@ -22971,7 +22951,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.8.0.tgz", "integrity": "sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==", "dev": true, - "peer": true, "requires": { "@babel/runtime": "^7.23.2", "aria-query": "^5.3.0", @@ -23014,7 +22993,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.1.tgz", "integrity": "sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw==", "dev": true, - "peer": true, "requires": { "array-includes": "^3.1.7", "array.prototype.findlast": "^1.2.4", @@ -23075,7 +23053,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, - "peer": true, "requires": {} }, "eslint-scope": { @@ -24205,7 +24182,6 @@ "version": "19.6.2", "resolved": "https://registry.npmjs.org/i18next/-/i18next-19.6.2.tgz", "integrity": "sha512-Zyd/Z32FY+sD+Eg6sLj5DeDSlrIN3WZ4onuOBRGcjDx/rvodsyUZ9TJ2Y+3aD9Vu8MPbiMU2WesIER/rs1ioyw==", - "peer": true, "requires": { "@babel/runtime": "^7.10.1" } @@ -24830,7 +24806,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz", "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", "dev": true, - "peer": true, "requires": { "@asamuzakjp/dom-selector": "^6.5.4", "cssstyle": "^5.3.0", @@ -25424,8 +25399,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "peer": true + "dev": true } } }, @@ -25538,7 +25512,8 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true + "dev": true, + "peer": true }, "mixin-deep": { "version": "1.3.2", @@ -25777,6 +25752,7 @@ "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", "dev": true, + "peer": true, "requires": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -26171,7 +26147,6 @@ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, - "peer": true, "requires": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -26278,7 +26253,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz", "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==", "dev": true, - "peer": true, "requires": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -26299,8 +26273,7 @@ "version": "3.2.5", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", - "dev": true, - "peer": true + "dev": true }, "prettier-linter-helpers": { "version": "1.0.0", @@ -26363,7 +26336,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "peer": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -26455,7 +26427,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz", "integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==", - "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -26474,7 +26445,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz", "integrity": "sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==", - "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -26665,7 +26635,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz", "integrity": "sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==", - "peer": true, "requires": { "@babel/runtime": "^7.1.2", "history": "^4.9.0", @@ -26818,7 +26787,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==", - "peer": true, "requires": { "loose-envify": "^1.4.0", "symbol-observable": "^1.2.0" @@ -27200,7 +27168,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", @@ -27888,7 +27855,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true + "dev": true, + "peer": true }, "strip-json-comments": { "version": "3.1.1", @@ -28257,8 +28225,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "peer": true + "dev": true } } }, @@ -28908,6 +28875,7 @@ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, + "peer": true, "requires": { "@types/json5": "^0.0.29", "json5": "^1.0.2", @@ -28920,6 +28888,7 @@ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, + "peer": true, "requires": { "minimist": "^1.2.0" } @@ -29242,7 +29211,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, - "peer": true, "requires": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -29264,8 +29232,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "peer": true + "dev": true } } }, @@ -29375,7 +29342,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, - "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -29417,7 +29383,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -29723,8 +29688,7 @@ "yaml": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "peer": true + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==" }, "yocto-queue": { "version": "1.0.0", diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 666898f5b39..6de5e5da6c7 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -758,5 +758,56 @@ "saturday_short": "Sat", "upstream_dns_cache_configuration": "Upstream DNS cache configuration", "enable_upstream_dns_cache": "Enable DNS caching for this client's custom upstream configuration", - "dns_cache_size": "DNS cache size, in bytes" + "dns_cache_size": "DNS cache size, in bytes", + "ipset_settings": "IP Set", + "ipset_title": "IP Set Configuration", + "ipset_storage_mode": "Storage mode", + "ipset_storage_mode_desc": "Choose where to store IP Set rules: in AdGuard Home configuration or in an external file", + "ipset_mode_config": "Store rules in configuration", + "ipset_mode_file": "Store rules in external file", + "ipset_rules": "IP Set rules", + "ipset_rules_desc": "Add domains and their corresponding IP Set names. IP addresses resolved for these domains will be added to the specified IP Sets.", + "ipset_rules_from_file": "Rules are loaded from external file", + "ipset_no_rules": "No rules configured yet", + "ipset_add_rule": "Add rule", + "ipset_edit_rule": "Edit rule", + "ipset_confirm_delete": "Are you sure you want to delete this rule?", + "ipset_duplicate_rule": "This rule already exists", + "ipset_invalid_rule": "Invalid rule format", + "ipset_domains": "Domains", + "ipset_domains_desc": "Comma-separated list of domain names (wildcards like *.example.com are supported)", + "ipset_names": "IP Set names", + "ipset_names_desc": "Comma-separated list of IP Set names (only letters, digits, underscore and hyphen allowed)", + "ipset_example": "Example", + "ipset_file_path": "File path", + "ipset_file_path_desc": "Absolute path to a file containing IP Set rules (one rule per line, comments starting with # are supported)", + "ipset_file_path_required": "File path cannot be empty", + "ipset_info_title": "IP Set information", + "ipset_info_desc": "IP Set allows adding resolved IP addresses to Linux ipset lists for advanced firewall rules.", + "ipset_info_linux_only": "This feature is only supported on Linux systems", + "ipset_info_format": "Rule format: DOMAIN[,DOMAIN,...]/IPSET_NAME[,IPSET_NAME,...]", + "ipset_error_unknown": "Unknown IPSet", + "ipset_error_not_exists": "The specified IPSet does not exist in the system. Please create it first using the 'ipset create' command.", + "ipset_error_save_failed": "Failed to save IPSet configuration. Please check your rules and ensure all IPSets exist in the system.", + "ipset_autocreate_title": "Create IPSet if it is missing", + "ipset_autocreate_desc": "Automatically create IPSets if they don't exist in the system when AdGuard Home starts", + "ipset_autocreate_enable": "Enable automatic IPSet creation", + "ipset_autocreate_sets": "IPSet Definitions", + "ipset_autocreate_sets_desc": "Define IPSets to be created automatically. You can specify multiple IPSet names separated by commas to create a group with the same settings. If an IPSet already exists, creation will be skipped and logged.", + "ipset_autocreate_add": "Add IPSet Definition", + "ipset_autocreate_edit": "Edit IPSet Definition", + "ipset_autocreate_name": "IPSet Name", + "ipset_autocreate_name_desc": "Name of the IPSet or multiple names separated by commas (letters, digits, underscore, hyphen)", + "ipset_autocreate_type": "Type", + "ipset_autocreate_type_desc": "Type of IPSet entries", + "ipset_autocreate_type_ip": "IP addresses (hash:ip)", + "ipset_autocreate_type_net": "IP networks (hash:net)", + "ipset_autocreate_family": "IP Family", + "ipset_autocreate_family_desc": "Protocol family for this IPSet", + "ipset_autocreate_family_ipv4": "IPv4 (inet)", + "ipset_autocreate_family_ipv6": "IPv6 (inet6)", + "ipset_autocreate_timeout": "Timeout (seconds)", + "ipset_autocreate_timeout_desc": "Entry timeout in seconds (0 or empty = no timeout, entries permanent)", + "ipset_autocreate_no_sets": "No IPSet definitions configured", + "ipset_autocreate_confirm_delete": "Are you sure you want to delete this IPSet definition?" } diff --git a/client/src/__locales/ru.json b/client/src/__locales/ru.json index 31628aa0b05..ab8055069e8 100644 --- a/client/src/__locales/ru.json +++ b/client/src/__locales/ru.json @@ -758,5 +758,56 @@ "saturday_short": "Сб", "upstream_dns_cache_configuration": "Конфигурация кеша upstream DNS-серверов", "enable_upstream_dns_cache": "Включить кеширование для пользовательской конфигурации upstream-серверов этого клиента", - "dns_cache_size": "Размер DNS-кеша в байтах" + "dns_cache_size": "Размер DNS-кеша в байтах", + "ipset_settings": "IP Set", + "ipset_title": "Настройка IP Set", + "ipset_storage_mode": "Режим хранения", + "ipset_storage_mode_desc": "Выберите, где хранить правила IP Set: в конфигурации AdGuard Home или во внешнем файле", + "ipset_mode_config": "Хранить правила в конфигурации", + "ipset_mode_file": "Хранить правила во внешнем файле", + "ipset_rules": "Правила IP Set", + "ipset_rules_desc": "Добавьте домены и соответствующие имена IP Set. IP-адреса, полученные для этих доменов, будут добавлены в указанные IP Set списки.", + "ipset_rules_from_file": "Правила загружаются из внешнего файла", + "ipset_no_rules": "Правила ещё не настроены", + "ipset_add_rule": "Добавить правило", + "ipset_edit_rule": "Редактировать правило", + "ipset_confirm_delete": "Вы уверены, что хотите удалить это правило?", + "ipset_duplicate_rule": "Это правило уже существует", + "ipset_invalid_rule": "Неверный формат правила", + "ipset_domains": "Домены", + "ipset_domains_desc": "Список доменных имён через запятую (поддерживаются маски вида *.example.com)", + "ipset_names": "Имена IP Set", + "ipset_names_desc": "Список имён IP Set через запятую (разрешены только буквы, цифры, подчёркивание и дефис)", + "ipset_example": "Пример", + "ipset_file_path": "Путь к файлу", + "ipset_file_path_desc": "Абсолютный путь к файлу с правилами IP Set (одно правило на строку, поддерживаются комментарии, начинающиеся с #)", + "ipset_file_path_required": "Путь к файлу не может быть пустым", + "ipset_info_title": "Информация об IP Set", + "ipset_info_desc": "IP Set позволяет добавлять полученные IP-адреса в списки Linux ipset для расширенных правил файрвола.", + "ipset_info_linux_only": "Эта функция поддерживается только в системах Linux", + "ipset_info_format": "Формат правила: DOMAIN[,DOMAIN,...]/IPSET_NAME[,IPSET_NAME,...]", + "ipset_error_unknown": "Неизвестный IPSet", + "ipset_error_not_exists": "Указанный IPSet не существует в системе. Сначала создайте его с помощью команды 'ipset create'.", + "ipset_error_save_failed": "Не удалось сохранить конфигурацию IPSet. Проверьте правила и убедитесь, что все IPSet существуют в системе.", + "ipset_autocreate_title": "Создать IPSet, если он отсутствует", + "ipset_autocreate_desc": "Автоматически создавать IPSet, если они не существуют в системе при запуске AdGuard Home", + "ipset_autocreate_enable": "Включить автоматическое создание IPSet", + "ipset_autocreate_sets": "Определения IPSet", + "ipset_autocreate_sets_desc": "Определите IPSet для автоматического создания. Вы можете указать несколько имён IPSet через запятую, чтобы создать группу с одинаковыми настройками. Если IPSet уже существует, создание будет пропущено и залогировано.", + "ipset_autocreate_add": "Добавить определение IPSet", + "ipset_autocreate_edit": "Редактировать определение IPSet", + "ipset_autocreate_name": "Имя IPSet", + "ipset_autocreate_name_desc": "Имя IPSet или несколько имён через запятую (буквы, цифры, подчёркивание, дефис)", + "ipset_autocreate_type": "Тип", + "ipset_autocreate_type_desc": "Тип записей IPSet", + "ipset_autocreate_type_ip": "IP-адреса (hash:ip)", + "ipset_autocreate_type_net": "IP-сети (hash:net)", + "ipset_autocreate_family": "Семейство IP", + "ipset_autocreate_family_desc": "Семейство протоколов для этого IPSet", + "ipset_autocreate_family_ipv4": "IPv4 (inet)", + "ipset_autocreate_family_ipv6": "IPv6 (inet6)", + "ipset_autocreate_timeout": "Таймаут (секунды)", + "ipset_autocreate_timeout_desc": "Таймаут записи в секундах (0 или пусто = без таймаута, записи постоянные)", + "ipset_autocreate_no_sets": "Определения IPSet не настроены", + "ipset_autocreate_confirm_delete": "Вы уверены, что хотите удалить это определение IPSet?" } diff --git a/client/src/actions/dnsConfig.ts b/client/src/actions/dnsConfig.ts index 9b8f9288db6..6bbbccf2b91 100644 --- a/client/src/actions/dnsConfig.ts +++ b/client/src/actions/dnsConfig.ts @@ -70,15 +70,28 @@ export const setDnsConfig = (config: any) => async (dispatch: any) => { await apiClient.setDnsConfig(data); + // Reload configuration from server to ensure UI reflects saved state + const updatedConfig = await apiClient.getDnsConfig(); + if (hasDnsSettings) { dispatch(addSuccessToast('updated_upstream_dns_toast')); } else { dispatch(addSuccessToast('config_successfully_saved')); } - dispatch(setDnsConfigSuccess(config)); + dispatch(setDnsConfigSuccess(updatedConfig)); } catch (error) { - dispatch(addErrorToast({ error })); + // Parse error message to provide better user feedback + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check if error is related to IPSet + if (errorMessage.includes('ipset') || errorMessage.includes('unknown ipset')) { + const customError = new Error(i18next.t('ipset_error_save_failed')); + dispatch(addErrorToast({ error: customError })); + } else { + dispatch(addErrorToast({ error })); + } + dispatch(setDnsConfigFailure()); } }; diff --git a/client/src/components/App/index.tsx b/client/src/components/App/index.tsx index b00ed8aaa63..06a46bb9cab 100644 --- a/client/src/components/App/index.tsx +++ b/client/src/components/App/index.tsx @@ -35,6 +35,7 @@ import Dns from '../../containers/Dns'; import Encryption from '../../containers/Encryption'; import Dhcp from '../Settings/Dhcp'; +import Ipset from '../Settings/Ipset'; import Clients from '../../containers/Clients'; import DnsBlocklist from '../../containers/DnsBlocklist'; import DnsAllowlist from '../../containers/DnsAllowlist'; @@ -77,6 +78,10 @@ const ROUTES = [ path: SETTINGS_URLS.dhcp, component: Dhcp, }, + { + path: SETTINGS_URLS.ipset, + component: Ipset, + }, { path: SETTINGS_URLS.clients, component: Clients, diff --git a/client/src/components/Header/Menu.tsx b/client/src/components/Header/Menu.tsx index b4ad0712468..59fdbd5f0c9 100644 --- a/client/src/components/Header/Menu.tsx +++ b/client/src/components/Header/Menu.tsx @@ -57,6 +57,10 @@ const SETTINGS_ITEMS = [ route: SETTINGS_URLS.dhcp, text: 'dhcp_settings', }, + { + route: SETTINGS_URLS.ipset, + text: 'ipset_settings', + }, ]; const FILTERS_ITEMS = [ diff --git a/client/src/components/Settings/Ipset/AutoCreateModal.tsx b/client/src/components/Settings/Ipset/AutoCreateModal.tsx new file mode 100644 index 00000000000..454a673f6aa --- /dev/null +++ b/client/src/components/Settings/Ipset/AutoCreateModal.tsx @@ -0,0 +1,255 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useForm, Controller } from 'react-hook-form'; + +import { Input } from '../../ui/Controls/Input'; +import { Radio } from '../../ui/Controls/Radio'; + +export interface IpsetDefinition { + name: string; + type: string; + family: string; + timeout: number; +} + +interface AutoCreateModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (definitions: IpsetDefinition[]) => void; + initialDefinition?: IpsetDefinition | null; + title: string; +} + +const AutoCreateModal: React.FC = ({ + isOpen, + onClose, + onSave, + initialDefinition, + title, +}) => { + const { t } = useTranslation(); + + const { control, handleSubmit, reset, formState: { errors } } = useForm({ + defaultValues: { + name: initialDefinition?.name || '', + type: initialDefinition?.type || 'hash:ip', + family: initialDefinition?.family || 'inet', + timeout: initialDefinition?.timeout || 0, + }, + }); + + useEffect(() => { + if (isOpen) { + reset({ + name: initialDefinition?.name || '', + type: initialDefinition?.type || 'hash:ip', + family: initialDefinition?.family || 'inet', + timeout: initialDefinition?.timeout || 0, + }); + } + }, [isOpen, initialDefinition, reset]); + + const onSubmit = (data: IpsetDefinition) => { + // Split names by comma and create multiple definitions + const names = data.name + .split(',') + .map(n => n.trim()) + .filter(n => n.length > 0); + + const definitions = names.map(name => ({ + name, + type: data.type, + family: data.family, + timeout: data.timeout, + })); + + onSave(definitions); + reset(); + onClose(); + }; + + const handleFormSubmit = (e: React.FormEvent) => { + e.stopPropagation(); + handleSubmit(onSubmit)(e); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + handleClose(); + } + }; + + const validateName = (value: string) => { + if (!value || value.trim() === '') { + return t('ipset_file_path_required'); + } + + // Split by comma and validate each name + const names = value + .split(',') + .map(n => n.trim()) + .filter(n => n.length > 0); + + if (names.length === 0) { + return t('ipset_file_path_required'); + } + + // Check each name individually + for (const name of names) { + if (!/^[A-Za-z0-9_-]+$/.test(name)) { + return `${t('ipset_invalid_rule')}: "${name}"`; + } + } + + return undefined; + }; + + const validateTimeout = (value: number) => { + if (value < 0) { + return 'Timeout must be non-negative'; + } + return undefined; + }; + + if (!isOpen) { + return null; + } + + const typeOptions = [ + { value: 'hash:ip', label: t('ipset_autocreate_type_ip') }, + { value: 'hash:net', label: t('ipset_autocreate_type_net') }, + ]; + + const familyOptions = [ + { value: 'inet', label: t('ipset_autocreate_family_ipv4') }, + { value: 'inet6', label: t('ipset_autocreate_family_ipv6') }, + ]; + + return ( + <> +
+
+
e.stopPropagation()}> +
+
+
{title}
+ +
+
+
+
+ ( + + )} + /> +
+ +
+ +
+ {t('ipset_autocreate_type_desc')} +
+ ( + + )} + /> +
+ +
+ +
+ {t('ipset_autocreate_family_desc')} +
+ ( + + )} + /> +
+ +
+ ( + field.onChange(parseInt(e.target.value, 10) || 0)} + /> + )} + /> +
+
+
+ + +
+
+
+
+
+ + ); +}; + +export default AutoCreateModal; diff --git a/client/src/components/Settings/Ipset/AutoCreateTable.tsx b/client/src/components/Settings/Ipset/AutoCreateTable.tsx new file mode 100644 index 00000000000..caf1ed87ec3 --- /dev/null +++ b/client/src/components/Settings/Ipset/AutoCreateTable.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { IpsetDefinition } from './AutoCreateModal'; + +interface AutoCreateTableProps { + definitions: IpsetDefinition[]; + onEdit: (index: number, definition: IpsetDefinition) => void; + onDelete: (index: number) => void; + disabled?: boolean; +} + +const AutoCreateTable: React.FC = ({ + definitions, + onEdit, + onDelete, + disabled = false, +}) => { + const { t } = useTranslation(); + + if (definitions.length === 0) { + return ( +
+ {t('ipset_autocreate_no_sets')} +
+ ); + } + + const getTypeLabel = (type: string) => { + switch (type) { + case 'hash:ip': + return t('ipset_autocreate_type_ip'); + case 'hash:net': + return t('ipset_autocreate_type_net'); + default: + return type; + } + }; + + const getFamilyLabel = (family: string) => { + switch (family) { + case 'inet': + return t('ipset_autocreate_family_ipv4'); + case 'inet6': + return t('ipset_autocreate_family_ipv6'); + default: + return family; + } + }; + + return ( + + + + + + + + + + + + {definitions.map((def, index) => ( + + + + + + + + ))} + +
{t('ipset_autocreate_name')}{t('ipset_autocreate_type')}{t('ipset_autocreate_family')}{t('ipset_autocreate_timeout')}{t('actions_table_header')}
{def.name}{getTypeLabel(def.type)}{getFamilyLabel(def.family)}{def.timeout === 0 ? t('disabled') : `${def.timeout}s`} + + +
+ ); +}; + +export default AutoCreateTable; diff --git a/client/src/components/Settings/Ipset/Form.tsx b/client/src/components/Settings/Ipset/Form.tsx new file mode 100644 index 00000000000..02d0797d832 --- /dev/null +++ b/client/src/components/Settings/Ipset/Form.tsx @@ -0,0 +1,328 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import RulesTable from './RulesTable'; +import RuleModal from './RuleModal'; +import AutoCreateTable from './AutoCreateTable'; +import AutoCreateModal, { IpsetDefinition } from './AutoCreateModal'; +import { parseIPSetRule, isDuplicateRule, validateIPSetRule } from '../../../helpers/ipset'; +import { Radio } from '../../ui/Controls/Radio'; +import { Input } from '../../ui/Controls/Input'; +import { Checkbox } from '../../ui/Controls/Checkbox'; + +interface IpsetCreateConfig { + enabled: boolean; + sets: IpsetDefinition[]; +} + +interface FormProps { + initialRules: string[]; + initialFilePath: string; + initialIpsetCreate: IpsetCreateConfig | null; + onSubmit: (data: { ipset: string[]; ipset_file: string; ipset_create: IpsetCreateConfig | null }) => void; + processing: boolean; +} + +type StorageMode = 'config' | 'file'; + +const Form: React.FC = ({ initialRules, initialFilePath, initialIpsetCreate, onSubmit, processing }) => { + const { t } = useTranslation(); + + // Determine initial mode + const initialMode: StorageMode = initialFilePath && initialFilePath.trim() !== '' ? 'file' : 'config'; + + const [mode, setMode] = useState(initialMode); + const [rules, setRules] = useState(initialRules); + const [filePath, setFilePath] = useState(initialFilePath); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [isDirty, setIsDirty] = useState(false); + + // AutoCreate state + const [autoCreateEnabled, setAutoCreateEnabled] = useState(initialIpsetCreate?.enabled || false); + const [autoCreateSets, setAutoCreateSets] = useState(initialIpsetCreate?.sets || []); + const [isAutoCreateModalOpen, setIsAutoCreateModalOpen] = useState(false); + const [editingAutoCreateIndex, setEditingAutoCreateIndex] = useState(null); + + // Update when initial values change + useEffect(() => { + setRules(initialRules); + setFilePath(initialFilePath); + setAutoCreateEnabled(initialIpsetCreate?.enabled || false); + setAutoCreateSets(initialIpsetCreate?.sets || []); + const newMode = initialFilePath && initialFilePath.trim() !== '' ? 'file' : 'config'; + setMode(newMode); + setIsDirty(false); + }, [initialRules, initialFilePath, initialIpsetCreate]); + + const handleModeChange = (newMode: StorageMode) => { + setMode(newMode); + setIsDirty(true); + }; + + const handleAddRule = () => { + setEditingIndex(null); + setIsModalOpen(true); + }; + + const handleEditRule = (index: number, _rule: string) => { + setEditingIndex(index); + setIsModalOpen(true); + }; + + const handleSaveRule = (newRule: string) => { + // Validate rule + const error = validateIPSetRule(newRule); + if (error) { + alert(`Invalid rule: ${error}`); + return; + } + + // Check for duplicates (exclude current rule if editing) + const otherRules = editingIndex !== null + ? rules.filter((_, i) => i !== editingIndex) + : rules; + + if (isDuplicateRule(newRule, otherRules)) { + alert(t('ipset_duplicate_rule')); + return; + } + + if (editingIndex !== null) { + // Edit existing rule + const newRules = [...rules]; + newRules[editingIndex] = newRule; + setRules(newRules); + } else { + // Add new rule + setRules([...rules, newRule]); + } + + setIsDirty(true); + }; + + const handleDeleteRule = (index: number) => { + if (window.confirm(t('ipset_confirm_delete'))) { + const newRules = rules.filter((_, i) => i !== index); + setRules(newRules); + setIsDirty(true); + } + }; + + const handleFilePathChange = (e: React.ChangeEvent) => { + setFilePath(e.target.value); + setIsDirty(true); + }; + + const handleAutoCreateEnabledChange = () => { + setAutoCreateEnabled(!autoCreateEnabled); + setIsDirty(true); + }; + + const handleAddAutoCreateSet = () => { + setEditingAutoCreateIndex(null); + setIsAutoCreateModalOpen(true); + }; + + const handleEditAutoCreateSet = (index: number, _definition: IpsetDefinition) => { + setEditingAutoCreateIndex(index); + setIsAutoCreateModalOpen(true); + }; + + const handleSaveAutoCreateSet = (definitions: IpsetDefinition[]) => { + if (editingAutoCreateIndex !== null) { + // When editing, replace the single item + const newSets = [...autoCreateSets]; + newSets[editingAutoCreateIndex] = definitions[0]; + setAutoCreateSets(newSets); + } else { + // When adding, append all new definitions + setAutoCreateSets([...autoCreateSets, ...definitions]); + } + setIsDirty(true); + }; + + const handleDeleteAutoCreateSet = (index: number) => { + if (window.confirm(t('ipset_autocreate_confirm_delete'))) { + const newSets = autoCreateSets.filter((_, i) => i !== index); + setAutoCreateSets(newSets); + setIsDirty(true); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const ipsetCreate: IpsetCreateConfig = { + enabled: autoCreateEnabled, + sets: autoCreateSets, + }; + + if (mode === 'file') { + if (!filePath || filePath.trim() === '') { + alert(t('ipset_file_path_required')); + return; + } + onSubmit({ ipset: [], ipset_file: filePath.trim(), ipset_create: ipsetCreate }); + } else { + // Validate all rules + const invalidRule = rules.find((rule) => validateIPSetRule(rule) !== undefined); + if (invalidRule) { + const error = validateIPSetRule(invalidRule); + alert(`Invalid rule "${invalidRule}": ${error}`); + return; + } + onSubmit({ ipset: rules, ipset_file: '', ipset_create: ipsetCreate }); + } + + setIsDirty(false); + }; + + const modeOptions = [ + { value: 'config', label: t('ipset_mode_config') }, + { value: 'file', label: t('ipset_mode_file') }, + ]; + + const editingRule = editingIndex !== null ? rules[editingIndex] : null; + const parsedEditingRule = editingRule ? parseIPSetRule(editingRule) : null; + + return ( +
+
+
+
+ +
{t('ipset_storage_mode_desc')}
+
+ handleModeChange(value as StorageMode)} + /> +
+
+
+ + {mode === 'file' ? ( +
+
+ +
+
+ ) : ( +
+
+ +
{t('ipset_rules_desc')}
+ + + + +
+
+ )} + +
+
+
+ +
+ {t('ipset_autocreate_desc')} +
+ +
+ + {autoCreateEnabled && ( +
+ +
{t('ipset_autocreate_sets_desc')}
+ + + + +
+ )} +
+ +
+
+ {t('ipset_info_title')}: +

{t('ipset_info_desc')}

+
    +
  • {t('ipset_info_linux_only')}
  • +
  • {t('ipset_info_format')}
  • +
+
+
+
+ + + + setIsAutoCreateModalOpen(false)} + onSave={handleSaveAutoCreateSet} + initialDefinition={editingAutoCreateIndex !== null ? autoCreateSets[editingAutoCreateIndex] : null} + title={editingAutoCreateIndex !== null ? t('ipset_autocreate_edit') : t('ipset_autocreate_add')} + /> + + setIsModalOpen(false)} + onSave={handleSaveRule} + initialDomains={parsedEditingRule?.domains.join(',') || ''} + initialIPSets={parsedEditingRule?.ipsets.join(',') || ''} + title={editingIndex !== null ? t('ipset_edit_rule') : t('ipset_add_rule')} + /> + + ); +}; + +export default Form; diff --git a/client/src/components/Settings/Ipset/RuleModal.tsx b/client/src/components/Settings/Ipset/RuleModal.tsx new file mode 100644 index 00000000000..833d41ee42b --- /dev/null +++ b/client/src/components/Settings/Ipset/RuleModal.tsx @@ -0,0 +1,170 @@ +import React, { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import { Input } from '../../ui/Controls/Input'; +import { validateDomainsInput, validateIPSetsInput, formatIPSetRule } from '../../../helpers/ipset'; + +interface RuleModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (rule: string) => void; + initialDomains?: string; + initialIPSets?: string; + title: string; +} + +interface FormData { + domains: string; + ipsets: string; +} + +const RuleModal: React.FC = ({ + isOpen, + onClose, + onSave, + initialDomains = '', + initialIPSets = '', + title, +}) => { + const { t } = useTranslation(); + + const { + handleSubmit, + control, + reset, + } = useForm({ + mode: 'onBlur', + defaultValues: { + domains: initialDomains, + ipsets: initialIPSets, + }, + }); + + // Update form values when props change + useEffect(() => { + if (isOpen) { + reset({ + domains: initialDomains, + ipsets: initialIPSets, + }); + } + }, [isOpen, initialDomains, initialIPSets, reset]); + + const onSubmit = (data: FormData) => { + const rule = formatIPSetRule({ + domains: data.domains.split(',').map((d) => d.trim()), + ipsets: data.ipsets.split(',').map((s) => s.trim()), + }); + onSave(rule); + reset(); + onClose(); + }; + + const handleFormSubmit = (e: React.FormEvent) => { + // Prevent the form submission from bubbling up to parent form + e.stopPropagation(); + handleSubmit(onSubmit)(e); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + // Only close if clicking on the backdrop itself, not on modal content + if (e.target === e.currentTarget) { + handleClose(); + } + }; + + if (!isOpen) { + return null; + } + + return ( + <> +
+
+
e.stopPropagation()} + > +
+
+
{title}
+ +
+
+
+
+ ( + + )} + /> +
+ +
+ ( + + )} + /> +
+ +
+
+ {t('ipset_example')}: +
+ example.com,*.example.org/my_ipset,blocked_ips +
+
+
+ + +
+
+
+
+
+ + ); +}; + +export default RuleModal; diff --git a/client/src/components/Settings/Ipset/RulesTable.tsx b/client/src/components/Settings/Ipset/RulesTable.tsx new file mode 100644 index 00000000000..d6ec7700c9b --- /dev/null +++ b/client/src/components/Settings/Ipset/RulesTable.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { parseIPSetRule } from '../../../helpers/ipset'; + +interface RulesTableProps { + rules: string[]; + onEdit: (index: number, rule: string) => void; + onDelete: (index: number) => void; + disabled?: boolean; +} + +const RulesTable: React.FC = ({ rules, onEdit, onDelete, disabled = false }) => { + const { t } = useTranslation(); + + if (rules.length === 0) { + return ( +
+ {disabled ? t('ipset_rules_from_file') : t('ipset_no_rules')} +
+ ); + } + + return ( +
+ + + + + + + + + + {rules.map((rule, index) => { + const parsed = parseIPSetRule(rule); + if (!parsed) { + return ( + + + + ); + } + + return ( + + + + + + ); + })} + +
{t('ipset_domains')}{t('ipset_names')}{t('actions_table_header')}
+ {t('ipset_invalid_rule')}: {rule} +
+ {parsed.domains.join(', ')} + + {parsed.ipsets.join(', ')} + + + +
+
+ ); +}; + +export default RulesTable; diff --git a/client/src/components/Settings/Ipset/index.tsx b/client/src/components/Settings/Ipset/index.tsx new file mode 100644 index 00000000000..e62e284dd1f --- /dev/null +++ b/client/src/components/Settings/Ipset/index.tsx @@ -0,0 +1,57 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; + +import PageTitle from '../../ui/PageTitle'; +import Loading from '../../ui/Loading'; +import Form from './Form'; + +import { getDnsConfig, setDnsConfig } from '../../../actions/dnsConfig'; +import { RootState } from '../../../initialState'; + +const Ipset: React.FC = () => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const processingGetConfig = useSelector((state: RootState) => state.dnsConfig.processingGetConfig); + const processingSetConfig = useSelector((state: RootState) => state.dnsConfig.processingSetConfig); + const ipset = useSelector((state: RootState) => state.dnsConfig.ipset || []); + const ipset_file = useSelector((state: RootState) => state.dnsConfig.ipset_file || ''); + const ipset_create = useSelector((state: RootState) => state.dnsConfig.ipset_create || null); + + useEffect(() => { + dispatch(getDnsConfig()); + }, [dispatch]); + + const handleSubmit = (data: { ipset: string[]; ipset_file: string; ipset_create: any }) => { + dispatch(setDnsConfig(data)); + }; + + if (processingGetConfig) { + return ( + <> + + + + ); + } + + return ( + <> + +
+
+
+
+
+ + ); +}; + +export default Ipset; diff --git a/client/src/helpers/constants.ts b/client/src/helpers/constants.ts index d4e7c940954..cee1436fffb 100644 --- a/client/src/helpers/constants.ts +++ b/client/src/helpers/constants.ts @@ -145,6 +145,7 @@ export const SETTINGS_URLS = { encryption: '/encryption', dhcp: '/dhcp', dns: '/dns', + ipset: '/ipset', settings: '/settings', clients: '/clients', }; diff --git a/client/src/helpers/ipset.ts b/client/src/helpers/ipset.ts new file mode 100644 index 00000000000..8e2c430b93f --- /dev/null +++ b/client/src/helpers/ipset.ts @@ -0,0 +1,183 @@ +/** + * IPSet utilities for parsing and validating ipset rules + * Rule format: DOMAIN[,DOMAIN,...]/IPSET_NAME[,IPSET_NAME,...] + */ + +export interface IPSetRule { + domains: string[]; + ipsets: string[]; +} + +/** + * Parse ipset rule string into domains and ipsets + */ +export const parseIPSetRule = (rule: string): IPSetRule | null => { + const trimmedRule = rule.trim(); + + if (!trimmedRule) { + return null; + } + + const separatorIndex = trimmedRule.indexOf('/'); + + if (separatorIndex === -1) { + return null; + } + + const domainsPart = trimmedRule.substring(0, separatorIndex); + const ipsetsPart = trimmedRule.substring(separatorIndex + 1); + + const domains = domainsPart + .split(',') + .map((d) => d.trim()) + .filter((d) => d.length > 0); + + const ipsets = ipsetsPart + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + if (domains.length === 0 || ipsets.length === 0) { + return null; + } + + return { domains, ipsets }; +}; + +/** + * Format IPSetRule back to string + */ +export const formatIPSetRule = (rule: IPSetRule): string => { + return `${rule.domains.join(',')}/${rule.ipsets.join(',')}`; +}; + +/** + * Validate domain name or wildcard pattern + */ +export const validateDomain = (domain: string): string | undefined => { + if (!domain || domain.trim().length === 0) { + return 'Domain cannot be empty'; + } + + const trimmed = domain.trim(); + + // Basic domain validation (allows wildcards like *.example.com) + const domainRegex = /^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; + + if (!domainRegex.test(trimmed)) { + return 'Invalid domain format'; + } + + return undefined; +}; + +/** + * Validate ipset name + */ +export const validateIPSetName = (name: string): string | undefined => { + if (!name || name.trim().length === 0) { + return 'IPSet name cannot be empty'; + } + + const trimmed = name.trim(); + + // IPSet names: letters, digits, underscore, hyphen + const ipsetNameRegex = /^[a-zA-Z0-9_-]+$/; + + if (!ipsetNameRegex.test(trimmed)) { + return 'Invalid IPSet name (only letters, digits, _, - allowed)'; + } + + return undefined; +}; + +/** + * Validate complete ipset rule string + */ +export const validateIPSetRule = (rule: string): string | undefined => { + const trimmedRule = rule.trim(); + + if (!trimmedRule) { + return 'Rule cannot be empty'; + } + + const parsed = parseIPSetRule(trimmedRule); + + if (!parsed) { + return 'Invalid rule format. Expected: DOMAIN[,DOMAIN,...]/IPSET_NAME[,IPSET_NAME,...]'; + } + + // Validate each domain + const invalidDomain = parsed.domains.find((domain) => validateDomain(domain) !== undefined); + if (invalidDomain) { + const domainError = validateDomain(invalidDomain); + return `Domain "${invalidDomain}": ${domainError}`; + } + + // Validate each ipset name + const invalidIpset = parsed.ipsets.find((ipset) => validateIPSetName(ipset) !== undefined); + if (invalidIpset) { + const ipsetError = validateIPSetName(invalidIpset); + return `IPSet "${invalidIpset}": ${ipsetError}`; + } + + return undefined; +}; + +/** + * Validate domains input (comma-separated) + */ +export const validateDomainsInput = (input: string): string | undefined => { + const trimmed = input.trim(); + + if (!trimmed) { + return 'At least one domain is required'; + } + + const domains = trimmed.split(',').map((d) => d.trim()).filter((d) => d.length > 0); + + if (domains.length === 0) { + return 'At least one domain is required'; + } + + const invalidDomain = domains.find((domain) => validateDomain(domain) !== undefined); + if (invalidDomain) { + const error = validateDomain(invalidDomain); + return `Domain "${invalidDomain}": ${error}`; + } + + return undefined; +}; + +/** + * Validate ipsets input (comma-separated) + */ +export const validateIPSetsInput = (input: string): string | undefined => { + const trimmed = input.trim(); + + if (!trimmed) { + return 'At least one IPSet name is required'; + } + + const ipsets = trimmed.split(',').map((s) => s.trim()).filter((s) => s.length > 0); + + if (ipsets.length === 0) { + return 'At least one IPSet name is required'; + } + + const invalidIpset = ipsets.find((ipset) => validateIPSetName(ipset) !== undefined); + if (invalidIpset) { + const error = validateIPSetName(invalidIpset); + return `IPSet "${invalidIpset}": ${error}`; + } + + return undefined; +}; + +/** + * Check if rule is duplicate + */ +export const isDuplicateRule = (rule: string, existingRules: string[]): boolean => { + const trimmedRule = rule.trim(); + return existingRules.some((r) => r.trim() === trimmedRule); +}; diff --git a/client/src/initialState.ts b/client/src/initialState.ts index 979f33ca826..76bdd815f29 100644 --- a/client/src/initialState.ts +++ b/client/src/initialState.ts @@ -333,6 +333,17 @@ export type DnsConfigData = { cache_ttl_max?: number; cache_ttl_min?: number; cache_optimistic?: boolean; + ipset?: string[]; + ipset_file?: string; + ipset_create?: { + enabled: boolean; + sets: Array<{ + name: string; + type: string; + family: string; + timeout: number; + }>; + } | null; }; export type FilteringData = { diff --git a/client/src/reducers/dnsConfig.ts b/client/src/reducers/dnsConfig.ts index 2049577122b..23261ef7adb 100644 --- a/client/src/reducers/dnsConfig.ts +++ b/client/src/reducers/dnsConfig.ts @@ -26,6 +26,9 @@ const dnsConfig = handleActions( bootstrap_dns, local_ptr_upstreams, ratelimit_whitelist, + ipset, + ipset_file, + ipset_create, ...values } = payload; @@ -39,6 +42,9 @@ const dnsConfig = handleActions( bootstrap_dns: (bootstrap_dns && bootstrap_dns.join('\n')) || '', local_ptr_upstreams: (local_ptr_upstreams && local_ptr_upstreams.join('\n')) || '', ratelimit_whitelist: (ratelimit_whitelist && ratelimit_whitelist.join('\n')) || '', + ipset: ipset || [], + ipset_file: ipset_file || '', + ipset_create: ipset_create || null, processingGetConfig: false, upstream_mode: upstream_mode === '' ? DNS_REQUEST_OPTIONS.LOAD_BALANCING : upstream_mode, }; @@ -71,6 +77,8 @@ const dnsConfig = handleActions( disable_ipv6: false, dnssec_enabled: false, upstream_dns_file: '', + ipset: [], + ipset_file: '', }, ); diff --git a/internal/dnsforward/config.go b/internal/dnsforward/config.go index 4e3411aaa19..a3fba8e4bf7 100644 --- a/internal/dnsforward/config.go +++ b/internal/dnsforward/config.go @@ -157,11 +157,38 @@ type Config struct { // The format is the same as in [IpsetList]. IpsetListFileName string `yaml:"ipset_file"` + // IpsetCreate contains the configuration for automatic ipset creation. + IpsetCreate *IpsetCreateConfig `yaml:"ipset_create"` + // BootstrapPreferIPv6, if true, instructs the bootstrapper to prefer IPv6 // addresses to IPv4 ones for DoH, DoQ, and DoT. BootstrapPreferIPv6 bool `yaml:"bootstrap_prefer_ipv6"` } +// IpsetCreateConfig contains configuration for automatic ipset creation. +type IpsetCreateConfig struct { + // Enabled indicates whether automatic ipset creation is enabled. + Enabled bool `yaml:"enabled"` + + // Sets is the list of ipsets to create if they don't exist. + Sets []IpsetSetConfig `yaml:"sets"` +} + +// IpsetSetConfig contains configuration for a single ipset. +type IpsetSetConfig struct { + // Name is the name of the ipset. + Name string `yaml:"name"` + + // Type is the type of the ipset (e.g., "hash:ip", "hash:net"). + Type string `yaml:"type"` + + // Family is the IP family ("inet" for IPv4, "inet6" for IPv6). + Family string `yaml:"family"` + + // Timeout is the timeout in seconds for entries (0 means no timeout). + Timeout uint32 `yaml:"timeout"` +} + // EDNSClientSubnet is the settings list for EDNS Client Subnet. type EDNSClientSubnet struct { // CustomIP for EDNS Client Subnet. diff --git a/internal/dnsforward/dnsforward.go b/internal/dnsforward/dnsforward.go index 44ad1e9d189..4d91e5a1924 100644 --- a/internal/dnsforward/dnsforward.go +++ b/internal/dnsforward/dnsforward.go @@ -646,6 +646,12 @@ func (s *Server) prepareLocalResolvers(ctx context.Context) (uc *proxy.UpstreamC // the primary DNS proxy instance. It assumes s.serverLock is locked or the // Server not running. func (s *Server) prepareInternalDNS(ctx context.Context) (err error) { + // Create ipsets if auto-creation is enabled + err = s.createIpsets(ctx, s.conf.IpsetCreate) + if err != nil { + return fmt.Errorf("creating ipsets: %w", err) + } + ipsetList, err := s.prepareIpsetListSettings(ctx) if err != nil { return fmt.Errorf("preparing ipset settings: %w", err) diff --git a/internal/dnsforward/http.go b/internal/dnsforward/http.go index 29411a55130..77bf2987217 100644 --- a/internal/dnsforward/http.go +++ b/internal/dnsforward/http.go @@ -125,6 +125,42 @@ type jsonDNSConfig struct { // systemResolvers to the front-end. It's not a pointer to the slice since // there is no need to omit it while decoding from JSON. DefaultLocalPTRUpstreams []string `json:"default_local_ptr_upstreams,omitempty"` + + // IPSet is the ipset configuration that allows adding IP addresses of + // specified domain names to an ipset list. The format is: + // DOMAIN[,DOMAIN].../IPSET_NAME[,IPSET_NAME]... + IPSet *[]string `json:"ipset"` + + // IPSetFile is the path to a file containing ipset configuration. + // The format is the same as IPSet. This field takes precedence over IPSet. + IPSetFile *string `json:"ipset_file"` + + // IPSetCreate contains configuration for automatic ipset creation. + IPSetCreate *jsonIpsetCreateConfig `json:"ipset_create"` +} + +// jsonIpsetCreateConfig contains configuration for automatic ipset creation. +type jsonIpsetCreateConfig struct { + // Enabled indicates whether automatic ipset creation is enabled. + Enabled bool `json:"enabled"` + + // Sets is the list of ipsets to create if they don't exist. + Sets []jsonIpsetSetConfig `json:"sets"` +} + +// jsonIpsetSetConfig contains configuration for a single ipset. +type jsonIpsetSetConfig struct { + // Name is the name of the ipset. + Name string `json:"name"` + + // Type is the type of the ipset (e.g., "hash:ip", "hash:net"). + Type string `json:"type"` + + // Family is the IP family ("inet" for IPv4, "inet6" for IPv6). + Family string `json:"family"` + + // Timeout is the timeout in seconds for entries (0 means no timeout). + Timeout uint32 `json:"timeout"` } // jsonUpstreamMode is a enumeration of upstream modes. @@ -174,6 +210,23 @@ func (s *Server) getDNSConfig(ctx context.Context) (c *jsonDNSConfig) { resolveClients := s.conf.AddrProcConf.UseRDNS usePrivateRDNS := s.conf.UsePrivateRDNS localPTRUpstreams := stringutil.CloneSliceOrEmpty(s.conf.LocalPTRResolvers) + ipsetList := stringutil.CloneSliceOrEmpty(s.conf.IpsetList) + ipsetFile := s.conf.IpsetListFileName + var ipsetCreate *jsonIpsetCreateConfig + if s.conf.IpsetCreate != nil { + ipsetCreate = &jsonIpsetCreateConfig{ + Enabled: s.conf.IpsetCreate.Enabled, + Sets: make([]jsonIpsetSetConfig, len(s.conf.IpsetCreate.Sets)), + } + for i, set := range s.conf.IpsetCreate.Sets { + ipsetCreate.Sets[i] = jsonIpsetSetConfig{ + Name: set.Name, + Type: set.Type, + Family: set.Family, + Timeout: set.Timeout, + } + } + } var upstreamMode jsonUpstreamMode switch s.conf.UpstreamMode { @@ -223,6 +276,9 @@ func (s *Server) getDNSConfig(ctx context.Context) (c *jsonDNSConfig) { LocalPTRUpstreams: &localPTRUpstreams, DefaultLocalPTRUpstreams: defPTRUps, DisabledUntil: protectionDisabledUntil, + IPSet: &ipsetList, + IPSetFile: &ipsetFile, + IPSetCreate: ipsetCreate, } } @@ -579,8 +635,12 @@ func (s *Server) handleSetConfig(w http.ResponseWriter, r *http.Request) { err = s.Reconfigure(ctx, nil) if err != nil { aghhttp.ErrorAndLog(ctx, l, r, w, http.StatusInternalServerError, "%s", err) + + return } } + + aghhttp.OK(ctx, l, w) } // setConfig sets the server parameters. shouldRestart is true if the server @@ -647,6 +707,24 @@ func setIfNotNil[T any](currentPtr, newPtr *T) (hasSet bool) { // shouldRestart is true if the server should be restarted to apply changes. // s.serverLock is expected to be locked. // +// ipsetSetsEqual compares two slices of IpsetSetConfig for equality. +func ipsetSetsEqual(a, b []IpsetSetConfig) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i].Name != b[i].Name || + a[i].Type != b[i].Type || + a[i].Family != b[i].Family || + a[i].Timeout != b[i].Timeout { + return false + } + } + + return true +} + // TODO(a.garipov): Some of these could probably be updated without a restart. // Inspect and consider refactoring. func (s *Server) setConfigRestartable(dc *jsonDNSConfig) (shouldRestart bool) { @@ -668,6 +746,8 @@ func (s *Server) setConfigRestartable(dc *jsonDNSConfig) (shouldRestart bool) { setIfNotNil(&s.conf.RatelimitSubnetLenIPv4, dc.RatelimitSubnetLenIPv4), setIfNotNil(&s.conf.RatelimitSubnetLenIPv6, dc.RatelimitSubnetLenIPv6), setIfNotNil(&s.conf.RatelimitWhitelist, dc.RatelimitWhitelist), + setIfNotNil(&s.conf.IpsetList, dc.IPSet), + setIfNotNil(&s.conf.IpsetListFileName, dc.IPSetFile), } { shouldRestart = shouldRestart || hasSet if shouldRestart { @@ -675,6 +755,39 @@ func (s *Server) setConfigRestartable(dc *jsonDNSConfig) (shouldRestart bool) { } } + // Handle ipset_create configuration + if dc.IPSetCreate != nil { + if s.conf.IpsetCreate == nil { + s.conf.IpsetCreate = &IpsetCreateConfig{} + } + + if s.conf.IpsetCreate.Enabled != dc.IPSetCreate.Enabled { + s.conf.IpsetCreate.Enabled = dc.IPSetCreate.Enabled + shouldRestart = true + } + + // Convert JSON sets to config sets + newSets := make([]IpsetSetConfig, len(dc.IPSetCreate.Sets)) + for i, jsonSet := range dc.IPSetCreate.Sets { + newSets[i] = IpsetSetConfig{ + Name: jsonSet.Name, + Type: jsonSet.Type, + Family: jsonSet.Family, + Timeout: jsonSet.Timeout, + } + } + + // Check if sets changed + if !ipsetSetsEqual(s.conf.IpsetCreate.Sets, newSets) { + s.conf.IpsetCreate.Sets = newSets + shouldRestart = true + } + } else if s.conf.IpsetCreate != nil && s.conf.IpsetCreate.Enabled { + // IPSetCreate was disabled + s.conf.IpsetCreate.Enabled = false + shouldRestart = true + } + if dc.Ratelimit != nil && s.conf.Ratelimit != *dc.Ratelimit { s.conf.Ratelimit = *dc.Ratelimit shouldRestart = true diff --git a/internal/dnsforward/ipset_create.go b/internal/dnsforward/ipset_create.go new file mode 100644 index 00000000000..74813489de9 --- /dev/null +++ b/internal/dnsforward/ipset_create.go @@ -0,0 +1,116 @@ +//go:build linux + +package dnsforward + +import ( + "context" + "fmt" + "time" + + "github.com/AdguardTeam/golibs/logutil/slogutil" + "github.com/digineo/go-ipset/v2" + "github.com/ti-mo/netfilter" +) + +// createIpsets creates ipsets defined in the configuration if they don't exist. +// It skips ipsets that already exist and logs the action. +func (s *Server) createIpsets(ctx context.Context, config *IpsetCreateConfig) error { + if config == nil || !config.Enabled || len(config.Sets) == 0 { + return nil + } + + s.logger.InfoContext(ctx, "creating ipsets if missing", "count", len(config.Sets)) + + for _, setConfig := range config.Sets { + err := s.createSingleIpset(ctx, setConfig) + if err != nil { + s.logger.ErrorContext( + ctx, + "failed to create ipset", + "name", setConfig.Name, + slogutil.KeyError, err, + ) + // Continue with next ipset instead of failing completely + continue + } + } + + return nil +} + +// createSingleIpset creates a single ipset if it doesn't exist. +func (s *Server) createSingleIpset(ctx context.Context, config IpsetSetConfig) error { + // Determine protocol family + var family netfilter.ProtoFamily + switch config.Family { + case "inet", "ipv4": + family = netfilter.ProtoIPv4 + case "inet6", "ipv6": + family = netfilter.ProtoIPv6 + default: + return fmt.Errorf("unknown family %q, expected inet or inet6", config.Family) + } + + // Connect to netfilter + conn, err := ipset.Dial(family, nil) + if err != nil { + return fmt.Errorf("dialing netfilter: %w", err) + } + defer func() { + closeErr := conn.Close() + if closeErr != nil { + s.logger.WarnContext( + ctx, + "closing ipset connection", + slogutil.KeyError, closeErr, + ) + } + }() + + // Check if ipset already exists + _, err = conn.Header(config.Name) + if err == nil { + // Ipset exists, skip creation + s.logger.InfoContext( + ctx, + "ipset already exists, skipping creation", + "name", config.Name, + ) + return nil + } + + // Create the ipset + s.logger.InfoContext( + ctx, + "creating ipset", + "name", config.Name, + "type", config.Type, + "family", config.Family, + "timeout", config.Timeout, + ) + + // Determine ipset type revision (typically 0 for basic types) + var revision uint8 = 0 + + // Prepare create options + var opts []ipset.CreateDataOption + + // Add timeout if specified + if config.Timeout > 0 { + opts = append(opts, ipset.CreateDataTimeout(time.Duration(config.Timeout)*time.Second)) + } + + err = conn.Create(config.Name, config.Type, revision, family, opts...) + if err != nil { + return fmt.Errorf("creating ipset %q: %w", config.Name, err) + } + + s.logger.InfoContext( + ctx, + "successfully created ipset", + "name", config.Name, + "type", config.Type, + ) + + return nil +} diff --git a/internal/dnsforward/ipset_create_others.go b/internal/dnsforward/ipset_create_others.go new file mode 100644 index 00000000000..48ed44b380e --- /dev/null +++ b/internal/dnsforward/ipset_create_others.go @@ -0,0 +1,13 @@ +//go:build !linux + +package dnsforward + +import ( + "context" +) + +// createIpsets is a stub for non-Linux systems. +func (s *Server) createIpsets(ctx context.Context, config *IpsetCreateConfig) error { + // IPSet is only supported on Linux + return nil +}