Compare commits

..

22 Commits

Author SHA1 Message Date
Ainar Garipov
7ade25d227 websvc: imp restart 2022-09-30 15:33:29 +03:00
Ainar Garipov
b448a3b5dc Merge branch 'master' into websvc-confin-manager 2022-09-30 15:27:54 +03:00
Ainar Garipov
4d404b887f Pull request: 4970-error-415
Updates #4970.

Squashed commit of the following:

commit 10365d9c8474e9d9735f581fb32b2892b2153cc4
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Fri Sep 30 14:23:06 2022 +0300

    all: imp docs, names

commit cff1103a0618a6430dc91e7e018febbf313c12ba
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Fri Sep 30 14:02:38 2022 +0300

    home: imp content-type check
2022-09-30 14:41:25 +03:00
Ainar Garipov
7b48863041 Pull request: upd-chlog
Merge in DNS/adguard-home from upd-chlog to master

Squashed commit of the following:

commit b53de96bc5d1bc0ff81ceb6c716614fd094913e7
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Thu Sep 29 19:46:36 2022 +0300

    all: upd chlog
2022-09-29 19:51:33 +03:00
Ainar Garipov
756b14a61d Pull request: HOFTIX-csrf
Merge in DNS/adguard-home from HOFTIX-csrf to master

Squashed commit of the following:

commit 75ab27bf6c52b80ab4e7347d7c254fa659eac244
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Thu Sep 29 18:45:54 2022 +0300

    all: imp cookie security; rm plain-text apis
2022-09-29 19:04:26 +03:00
Eugene Burkov
b71a5d86de Pull request: 4945 fix user rules
Merge in DNS/adguard-home from 4945-fix-user-rules to master

Updates #4945.
Updates #4871.

Squashed commit of the following:

commit 415a262e5af0821b658ed2a1b365d471f1452a6a
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 29 18:05:48 2022 +0300

    home: fix user rules
2022-09-29 18:30:35 +03:00
Ainar Garipov
d45fa5801e Pull request: upd-i18n
Merge in DNS/adguard-home from upd-i18n to master

Squashed commit of the following:

commit c8ad18e03d1d6206c3220751c5c720a5eef3e3a9
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Thu Sep 29 17:12:22 2022 +0300

    client: upd i18n
2022-09-29 17:17:27 +03:00
Eugene Burkov
47c9c946a3 Pull request: 4871 imp filtering
Merge in DNS/adguard-home from 4871-imp-filtering to master

Closes #4871.

Squashed commit of the following:

commit 618e7c558447703c114332708c94ef1b34362cf9
Merge: 41ff8ab7 11e4f091
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 22 19:27:08 2022 +0300

    Merge branch 'master' into 4871-imp-filtering

commit 41ff8ab755a87170e7334dedcae00f01dcca238a
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 22 19:26:11 2022 +0300

    filtering: imp code, log

commit e4ae1d1788406ffd7ef0fcc6df896a22b0c2db37
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Thu Sep 22 14:11:07 2022 +0300

    filtering: move handlers into single func

commit f7a340b4c10980f512ae935a156f02b0133a1627
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Sep 21 19:21:09 2022 +0300

    all: imp code

commit e064bf4d3de0283e4bda2aaf5b9822bb8a08f4a6
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 20:12:16 2022 +0300

    all: imp name

commit e7eda3905762f0821e1be1ac3cf77e0ecbedeff4
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 17:51:23 2022 +0300

    all: finally get rid of filtering

commit 188550d873e625cc2951583bb3a2eaad036745f5
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 17:36:03 2022 +0300

    filtering: merge refresh

commit e54ed9c7952b17e66b790c835269b28fbc26f9ca
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 17:16:23 2022 +0300

    filtering: merge filters

commit 32da31b754a319487d5f9d5e81e607d349b90180
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 14:48:13 2022 +0300

    filtering: imp docs

commit 43b0cafa7a27bb9b620c2ba50ccdddcf32cfcecc
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Tue Sep 20 14:38:04 2022 +0300

    all: imp code

commit 253a2ea6c92815d364546e34d631e406dd604644
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Mon Sep 19 20:43:15 2022 +0300

    filtering: rm important flag

commit 1b87f08f946389d410f13412c7e486290d5e752d
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Mon Sep 19 17:05:40 2022 +0300

    all: move filtering to the package

commit daa13499f1dd4fe475c4b75769e34f1eb0915bdf
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Mon Sep 19 15:13:55 2022 +0300

    all: finish merging

commit d6db75eb2e1f23528e9200ea51507eb793eefa3c
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Fri Sep 16 18:18:14 2022 +0300

    all: continue merging

commit 45b4c484deb7198a469aa18d719bb9dbe81e5d22
Author: Eugene Burkov <E.Burkov@AdGuard.COM>
Date:   Wed Sep 14 15:44:22 2022 +0300

    all: merge filtering types
2022-09-23 13:23:35 +03:00
Ainar Garipov
11e4f09165 Pull request: imp-scripts
Merge in DNS/adguard-home from imp-scripts to master

Squashed commit of the following:

commit ab63a8a2dd1b64287e00a2a6f747fd48b530709e
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed Sep 21 19:15:06 2022 +0300

    all: imp scripts; upd tools; doc
2022-09-21 19:21:13 +03:00
Ainar Garipov
c45c02de29 Pull request: imp-stalebot
Merge in DNS/adguard-home from imp-stalebot to master

Squashed commit of the following:

commit d1fb5c6da25eeb168c53abfc7af714827a5242cd
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed Sep 21 14:31:50 2022 +0300

    all: imp stalebot
2022-09-21 15:02:35 +03:00
Ainar Garipov
4fc045de11 Pull request: 4927-ddr-template
Updates #4927.

Squashed commit of the following:

commit 8cf080d5355261ced7e8b10de607cbf37e1d663d
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Tue Sep 20 15:18:48 2022 +0300

    dnsforward: fix doh template
2022-09-20 15:26:10 +03:00
Ildar Kamalov
cc2388e0c8 Pull request: 4815 fix query log modal on tablet
Updates #4815

Squashed commit of the following:

commit 148c39ac40963a593885b86a0c851b4010b68ab0
Merge: 3447611d ab6da05b
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Sep 20 13:21:06 2022 +0300

    Merge branch 'master' into 4815-tablet-view

commit 3447611dc0b1c7d2cc1f8235d1c469dd92736166
Author: Ildar Kamalov <ik@adguard.com>
Date:   Fri Sep 16 17:01:05 2022 +0300

    client: fix query log modal on tablet
2022-09-20 13:48:57 +03:00
Ainar Garipov
c20ca9b85e all: add tests; imp errors 2022-09-12 19:28:26 +03:00
Ainar Garipov
dffffec9d4 all: imp response; fmt 2022-09-12 16:01:31 +03:00
Ainar Garipov
1989c91c07 websvc: imp tests 2022-09-09 15:05:33 +03:00
Ainar Garipov
dbfc8ae362 all: mv v1 to next; imp tests, docs 2022-09-09 14:25:48 +03:00
Ainar Garipov
d74ba3cb9d Merge branch 'master' into websvc-confin-manager 2022-09-09 13:44:01 +03:00
Ainar Garipov
abcbdbed29 websvc: add test; imp names, docs 2022-09-02 18:52:22 +03:00
Ainar Garipov
8a65848da4 Merge branch 'master' into websvc-confin-manager 2022-09-02 17:38:22 +03:00
Ainar Garipov
b018e150e7 websvc: add tests; imp names 2022-08-31 19:11:00 +03:00
Ainar Garipov
dbdae5b4fc Merge branch 'master' into websvc-confin-manager 2022-08-31 19:09:48 +03:00
Ainar Garipov
27bd8bc58b websvc: add dns and http apis 2022-08-31 17:53:45 +03:00
115 changed files with 2800 additions and 1497 deletions

22
.github/stale.yml vendored
View File

@@ -4,15 +4,17 @@
'daysUntilClose': 15 'daysUntilClose': 15
# Issues with these labels will never be considered stale. # Issues with these labels will never be considered stale.
'exemptLabels': 'exemptLabels':
- 'bug' - 'bug'
- 'documentation' - 'documentation'
- 'enhancement' - 'enhancement'
- 'feature request' - 'feature request'
- 'help wanted' - 'help wanted'
- 'localization' - 'localization'
- 'needs investigation' - 'needs investigation'
- 'recurrent' - 'recurrent'
- 'research' - 'research'
# Set to true to ignore issues in a milestone.
'exemptMilestones': true
# Label to use when marking an issue as stale. # Label to use when marking an issue as stale.
'staleLabel': 'wontfix' 'staleLabel': 'wontfix'
# Comment to post when marking an issue as stale. Set to `false` to disable. # Comment to post when marking an issue as stale. Set to `false` to disable.
@@ -22,3 +24,5 @@
for your contributions. for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable. # Comment to post when closing a stale issue. Set to `false` to disable.
'closeComment': false 'closeComment': false
# Limit the number of actions per hour.
'limitPerRun': 1

View File

@@ -12,11 +12,75 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
<!-- <!--
## [v0.108.0] - 2022-12-01 (APPROX.) ## [v0.108.0] - TBA (APPROX.)
--> -->
### Security ### Security
- As an additional CSRF protection measure, AdGuard Home now ensures that
requests that change its state but have no body (such as `POST
/control/stats_reset` requests) do not have a `Content-Type` header set on
them ([#4970]).
### Fixed
- `only application/json is allowed` errors in various APIs ([#4970]).
[#4970]: https://github.com/AdguardTeam/AdGuardHome/issues/4970
<!--
## [v0.107.15] - 2022-10-26 (APPROX.)
See also the [v0.107.15 GitHub milestone][ms-v0.107.15].
[ms-v0.107.15]: https://github.com/AdguardTeam/AdGuardHome/milestone/51?closed=1
-->
## [v0.107.14] - 2022-09-29
See also the [v0.107.14 GitHub milestone][ms-v0.107.14].
### Security
A Cross-Site Request Forgery (CSRF) vulnerability has been discovered. The CVE
number is to be assigned. We thank Daniel Elkabes from Mend.io for reporting
this vulnerability to us.
#### `SameSite` Policy
The `SameSite` policy on the AdGuard Home session cookies is now set to `Lax`.
Which means that the only cross-site HTTP request for which the browser is
allowed to send the session cookie is navigating to the AdGuard Home domain.
**Users are strongly advised to log out, clear browser cache, and log in again
after updating.**
#### Removal Of Plain-Text APIs (BREAKING API CHANGE)
We have implemented several measures to prevent such vulnerabilities in the
future, but some of these measures break backwards compatibility for the sake of
better protection.
The following APIs, which previously accepted or returned `text/plain` data,
now accept or return data as JSON. All new formats for the request and response
bodies are documented in `openapi/openapi.yaml` and `openapi/CHANGELOG.md`.
- `GET /control/i18n/current_language`;
- `POST /control/dhcp/find_active_dhcp`;
- `POST /control/filtering/set_rules`;
- `POST /control/i18n/change_language`.
#### Stricter Content-Type Checks (BREAKING API CHANGE)
All JSON APIs that expect a body now check if the request actually has
`Content-Type` set to `application/json`.
#### Other Security Changes
- Weaker cipher suites that use the CBC (cipher block chaining) mode of - Weaker cipher suites that use the CBC (cipher block chaining) mode of
operation have been disabled ([#2993]). operation have been disabled ([#2993]).
@@ -25,19 +89,15 @@ and this project adheres to
- Support for plain (unencrypted) HTTP/2 ([#4930]). This is useful for AdGuard - Support for plain (unencrypted) HTTP/2 ([#4930]). This is useful for AdGuard
Home installations behind a reverse proxy. Home installations behind a reverse proxy.
### Fixed
- Incorrect path template in DDR responses ([#4927]).
[#2993]: https://github.com/AdguardTeam/AdGuardHome/issues/2993 [#2993]: https://github.com/AdguardTeam/AdGuardHome/issues/2993
[#4927]: https://github.com/AdguardTeam/AdGuardHome/issues/4927
[#4930]: https://github.com/AdguardTeam/AdGuardHome/issues/4930 [#4930]: https://github.com/AdguardTeam/AdGuardHome/issues/4930
<!--
## [v0.107.14] - 2022-10-05 (APPROX.)
See also the [v0.107.14 GitHub milestone][ms-v0.107.14].
[ms-v0.107.14]: https://github.com/AdguardTeam/AdGuardHome/milestone/50?closed=1 [ms-v0.107.14]: https://github.com/AdguardTeam/AdGuardHome/milestone/50?closed=1
-->
@@ -1236,11 +1296,12 @@ See also the [v0.104.2 GitHub milestone][ms-v0.104.2].
<!-- <!--
[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.14...HEAD [Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.15...HEAD
[v0.107.14]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.13...v0.107.14 [v0.107.15]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.14...v0.107.15
--> -->
[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.13...HEAD [Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.14...HEAD
[v0.107.14]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.13...v0.107.14
[v0.107.13]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.12...v0.107.13 [v0.107.13]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.12...v0.107.13
[v0.107.12]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.11...v0.107.12 [v0.107.12]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.11...v0.107.12
[v0.107.11]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.10...v0.107.11 [v0.107.11]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.10...v0.107.11

View File

@@ -34,7 +34,7 @@ YARN_INSTALL_FLAGS = $(YARN_FLAGS) --network-timeout 120000 --silent\
--ignore-engines --ignore-optional --ignore-platform\ --ignore-engines --ignore-optional --ignore-platform\
--ignore-scripts --ignore-scripts
V1API = 0 NEXTAPI = 0
# Macros for the build-release target. If FRONTEND_PREBUILT is 0, the # Macros for the build-release target. If FRONTEND_PREBUILT is 0, the
# default, the macro $(BUILD_RELEASE_DEPS_$(FRONTEND_PREBUILT)) expands # default, the macro $(BUILD_RELEASE_DEPS_$(FRONTEND_PREBUILT)) expands
@@ -63,7 +63,7 @@ ENV = env\
PATH="$${PWD}/bin:$$( "$(GO.MACRO)" env GOPATH )/bin:$${PATH}"\ PATH="$${PWD}/bin:$$( "$(GO.MACRO)" env GOPATH )/bin:$${PATH}"\
RACE='$(RACE)'\ RACE='$(RACE)'\
SIGN='$(SIGN)'\ SIGN='$(SIGN)'\
V1API='$(V1API)'\ NEXTAPI='$(NEXTAPI)'\
VERBOSE='$(VERBOSE)'\ VERBOSE='$(VERBOSE)'\
VERSION='$(VERSION)'\ VERSION='$(VERSION)'\

18
SECURITY.md Normal file
View File

@@ -0,0 +1,18 @@
# Security Policy
## Reporting a Vulnerability
Please send your vulnerability reports to <security@adguard.com>. To make sure
that your report reaches us, please:
1. Include the words “AdGuard Home” and “vulnerability” to the subject line as
well as a short description of the vulnerability. For example:
> AdGuard Home API vulnerability: possible XSS attack
2. Make sure that the message body contains a clear description of the
vulnerability.
If you have not received a reply to your email within 7 days, please make sure
to follow up with us again at <security@adguard.com>. Once again, make sure
that the word “vulnerability” is in the subject line.

View File

@@ -635,5 +635,6 @@
"parental_control": "Бацькоўскі кантроль", "parental_control": "Бацькоўскі кантроль",
"safe_browsing": "Бяспечны інтэрнэт", "safe_browsing": "Бяспечны інтэрнэт",
"served_from_cache": "{{value}} <i>(атрымана з кэша)</i>", "served_from_cache": "{{value}} <i>(атрымана з кэша)</i>",
"form_error_password_length": "Пароль павінен быць не менш за {{value}} сімвалаў" "form_error_password_length": "Пароль павінен быць не менш за {{value}} сімвалаў",
"anonymizer_notification": "<0>Заўвага:</0> Ананімізацыя IP уключана. Вы можаце адключыць яго ў <1>Агульных наладах</1> ."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Rodičovská ochrana", "parental_control": "Rodičovská ochrana",
"safe_browsing": "Bezpečné prohlížení", "safe_browsing": "Bezpečné prohlížení",
"served_from_cache": "{{value}} <i>(převzato z mezipaměti)</i>", "served_from_cache": "{{value}} <i>(převzato z mezipaměti)</i>",
"form_error_password_length": "Heslo musí být alespoň {{value}} znaků dlouhé" "form_error_password_length": "Heslo musí být alespoň {{value}} znaků dlouhé",
"anonymizer_notification": "<0>Poznámka:</0> Anonymizace IP je zapnuta. Můžete ji vypnout v <1>Obecných nastaveních</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Forældrekontrol", "parental_control": "Forældrekontrol",
"safe_browsing": "Sikker Browsing", "safe_browsing": "Sikker Browsing",
"served_from_cache": "{{value}} <i>(leveret fra cache)</i>", "served_from_cache": "{{value}} <i>(leveret fra cache)</i>",
"form_error_password_length": "Adgangskoden skal udgøre mindst {{value}} tegn." "form_error_password_length": "Adgangskoden skal udgøre mindst {{value}} tegn.",
"anonymizer_notification": "<0>Bemærk:</0> IP-anonymisering er aktiveret. Det kan deaktiveres via <1>Generelle indstillinger</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Kindersicherung", "parental_control": "Kindersicherung",
"safe_browsing": "Internetsicherheit", "safe_browsing": "Internetsicherheit",
"served_from_cache": "{{value}} <i>(aus dem Cache abgerufen)</i>", "served_from_cache": "{{value}} <i>(aus dem Cache abgerufen)</i>",
"form_error_password_length": "Das Passwort muss mindestens {{value}} Zeichen enthalten" "form_error_password_length": "Das Passwort muss mindestens {{value}} Zeichen enthalten",
"anonymizer_notification": "<0>Hinweis:</0> Die IP-Anonymisierung ist aktiviert. Sie können sie in den <1>Allgemeinen Einstellungen</1> deaktivieren."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Control parental", "parental_control": "Control parental",
"safe_browsing": "Navegación segura", "safe_browsing": "Navegación segura",
"served_from_cache": "{{value}} <i>(servido desde la caché)</i>", "served_from_cache": "{{value}} <i>(servido desde la caché)</i>",
"form_error_password_length": "La contraseña debe tener al menos {{value}} caracteres" "form_error_password_length": "La contraseña debe tener al menos {{value}} caracteres",
"anonymizer_notification": "<0>Nota:</0> La anonimización de IP está habilitada. Puedes deshabilitarla en <1>Configuración general</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Lapsilukko", "parental_control": "Lapsilukko",
"safe_browsing": "Turvallinen selaus", "safe_browsing": "Turvallinen selaus",
"served_from_cache": "{{value}} <i>(jaettu välimuistista)</i>", "served_from_cache": "{{value}} <i>(jaettu välimuistista)</i>",
"form_error_password_length": "Salasanan on oltava ainakin {{value}} merkkiä" "form_error_password_length": "Salasanan on oltava ainakin {{value}} merkkiä",
"anonymizer_notification": "<0>Huomioi:</0> IP-osoitteen anonymisointi on käytössä. Voit poistaa sen käytöstä <1>Yleisistä asetuksista</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Contrôle parental", "parental_control": "Contrôle parental",
"safe_browsing": "Navigation sécurisée", "safe_browsing": "Navigation sécurisée",
"served_from_cache": "{{value}} <i>(depuis le cache)</i>", "served_from_cache": "{{value}} <i>(depuis le cache)</i>",
"form_error_password_length": "Le mot de passe doit comporter au moins {{value}} caractères" "form_error_password_length": "Le mot de passe doit comporter au moins {{value}} caractères",
"anonymizer_notification": "<0>Note :</0> L'anonymisation IP est activée. Vous pouvez la désactiver dans les <1>paramètres généraux</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Roditeljska zaštita", "parental_control": "Roditeljska zaštita",
"safe_browsing": "Sigurno surfanje", "safe_browsing": "Sigurno surfanje",
"served_from_cache": "{{value}} <i>(dohvaćeno iz predmemorije)</i>", "served_from_cache": "{{value}} <i>(dohvaćeno iz predmemorije)</i>",
"form_error_password_length": "Lozinka mora imati najmanje {{value}} znakova" "form_error_password_length": "Lozinka mora imati najmanje {{value}} znakova",
"anonymizer_notification": "<0>Napomena:</0>IP anonimizacija je omogućena. Možete ju onemogućiti u <1>općim postavkama</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Szülői felügyelet", "parental_control": "Szülői felügyelet",
"safe_browsing": "Biztonságos böngészés", "safe_browsing": "Biztonságos böngészés",
"served_from_cache": "{{value}} <i>(gyorsítótárból kiszolgálva)</i>", "served_from_cache": "{{value}} <i>(gyorsítótárból kiszolgálva)</i>",
"form_error_password_length": "A jelszó legalább {{value}} karakter hosszú kell, hogy legyen" "form_error_password_length": "A jelszó legalább {{value}} karakter hosszú kell, hogy legyen",
"anonymizer_notification": "<0>Megjegyzés:</0> Az IP anonimizálás engedélyezve van. Az <1>Általános beállításoknál letilthatja</1> ."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Kontrol Orang Tua", "parental_control": "Kontrol Orang Tua",
"safe_browsing": "Penjelajahan Aman", "safe_browsing": "Penjelajahan Aman",
"served_from_cache": "{{value}} <i>(disajikan dari cache)</i>", "served_from_cache": "{{value}} <i>(disajikan dari cache)</i>",
"form_error_password_length": "Kata sandi harus minimal {{value}} karakter" "form_error_password_length": "Kata sandi harus minimal {{value}} karakter",
"anonymizer_notification": "<0>Catatan:</0> Anonimisasi IP diaktifkan. Anda dapat menonaktifkannya di <1>Pengaturan umum</1> ."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Controllo Parentale", "parental_control": "Controllo Parentale",
"safe_browsing": "Navigazione Sicura", "safe_browsing": "Navigazione Sicura",
"served_from_cache": "{{value}} <i>(fornito dalla cache)</i>", "served_from_cache": "{{value}} <i>(fornito dalla cache)</i>",
"form_error_password_length": "La password deve contenere almeno {{value}} caratteri" "form_error_password_length": "La password deve contenere almeno {{value}} caratteri",
"anonymizer_notification": "<0>Attenzione:</0> L'anonimizzazione dell'IP è abilitata. Puoi disabilitarla in <1>Impostazioni generali</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "ペアレンタルコントロール", "parental_control": "ペアレンタルコントロール",
"safe_browsing": "セーフブラウジング", "safe_browsing": "セーフブラウジング",
"served_from_cache": "{{value}} <i>(キャッシュから応答)</i>", "served_from_cache": "{{value}} <i>(キャッシュから応答)</i>",
"form_error_password_length": "パスワードは{{value}}文字以上にしてください" "form_error_password_length": "パスワードは{{value}}文字以上にしてください",
"anonymizer_notification": "【<0>注意</0>】IPの匿名化が有効になっています。 <1>一般設定</1>で無効にできます。"
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "자녀 보호", "parental_control": "자녀 보호",
"safe_browsing": "세이프 브라우징", "safe_browsing": "세이프 브라우징",
"served_from_cache": "{{value}} <i>(캐시에서 제공)</i>", "served_from_cache": "{{value}} <i>(캐시에서 제공)</i>",
"form_error_password_length": "비밀번호는 {{value}}자 이상이어야 합니다" "form_error_password_length": "비밀번호는 {{value}}자 이상이어야 합니다",
"anonymizer_notification": "<0>참고:</0> IP 익명화가 활성화되었습니다. <1>일반 설정</1>에서 비활성화할 수 있습니다."
} }

View File

@@ -557,7 +557,7 @@
"fastest_addr_desc": "Alle DNS-servers bevragen en het snelste IP adres terugkoppelen. Dit zal de DNS verzoeken vertragen omdat AdGuard Home moet wachten op de antwoorden van alles DNS-servers, maar verbetert wel de connectiviteit.", "fastest_addr_desc": "Alle DNS-servers bevragen en het snelste IP adres terugkoppelen. Dit zal de DNS verzoeken vertragen omdat AdGuard Home moet wachten op de antwoorden van alles DNS-servers, maar verbetert wel de connectiviteit.",
"autofix_warning_text": "Als je op \"Repareren\" klikt, configureert AdGuard Home jouw systeem om de AdGuard Home DNS-server te gebruiken.", "autofix_warning_text": "Als je op \"Repareren\" klikt, configureert AdGuard Home jouw systeem om de AdGuard Home DNS-server te gebruiken.",
"autofix_warning_list": "De volgende taken worden uitgevoerd: <0> Deactiveren van Systeem DNSStubListener</0> <0> DNS-serveradres instellen op 127.0.0.1 </0> <0> Symbolisch koppelingsdoel van /etc/resolv.conf vervangen door /run/systemd/resolve/resolv.conf </0> <0> Stop DNSStubListener (herlaad systemd-resolved service) </0>", "autofix_warning_list": "De volgende taken worden uitgevoerd: <0> Deactiveren van Systeem DNSStubListener</0> <0> DNS-serveradres instellen op 127.0.0.1 </0> <0> Symbolisch koppelingsdoel van /etc/resolv.conf vervangen door /run/systemd/resolve/resolv.conf </0> <0> Stop DNSStubListener (herlaad systemd-resolved service) </0>",
"autofix_warning_result": "Als gevolg hiervan worden alle DNS-verzoeken van je systeem standaard door AdGuard Home verwerkt.", "autofix_warning_result": "Als gevolg hiervan worden alle DNS-aanvragen van je systeem standaard door AdGuard Home verwerkt.",
"tags_title": "Labels", "tags_title": "Labels",
"tags_desc": "Je kunt labels selecteren die overeenkomen met de client. Labels kunnen worden opgenomen in de filterregels om ze \n nauwkeuriger toe te passen. <0>Meer informatie</0>.", "tags_desc": "Je kunt labels selecteren die overeenkomen met de client. Labels kunnen worden opgenomen in de filterregels om ze \n nauwkeuriger toe te passen. <0>Meer informatie</0>.",
"form_select_tags": "Client tags selecteren", "form_select_tags": "Client tags selecteren",
@@ -628,12 +628,13 @@
"original_response": "Oorspronkelijke reactie", "original_response": "Oorspronkelijke reactie",
"click_to_view_queries": "Klik om queries te bekijken", "click_to_view_queries": "Klik om queries te bekijken",
"port_53_faq_link": "Poort 53 wordt vaak gebruikt door services als DNSStubListener- of de systeem DNS-resolver. Lees a.u.b. <0>deze instructie</0> hoe dit is op te lossen.", "port_53_faq_link": "Poort 53 wordt vaak gebruikt door services als DNSStubListener- of de systeem DNS-resolver. Lees a.u.b. <0>deze instructie</0> hoe dit is op te lossen.",
"adg_will_drop_dns_queries": "AdGuard Home zal alle DNS-verzoeken van deze cliënt laten vervallen.", "adg_will_drop_dns_queries": "AdGuard Home zal alle DNS-aanvragen van deze cliënt laten vervallen.",
"filter_allowlist": "WAARSCHUWING: Deze actie zal ook de regel \"{{disallowed_rule}}\" uitsluiten van de lijst met toegestane clients.", "filter_allowlist": "WAARSCHUWING: Deze actie zal ook de regel \"{{disallowed_rule}}\" uitsluiten van de lijst met toegestane clients.",
"last_rule_in_allowlist": "Kan deze client niet weigeren omdat het uitsluiten van de regel \"{{disallowed_rule}}\" de lijst \"Toegestane clients\" zal UITSCHAKELEN.", "last_rule_in_allowlist": "Kan deze client niet weigeren omdat het uitsluiten van de regel \"{{disallowed_rule}}\" de lijst \"Toegestane clients\" zal UITSCHAKELEN.",
"use_saved_key": "De eerder opgeslagen sleutel gebruiken", "use_saved_key": "De eerder opgeslagen sleutel gebruiken",
"parental_control": "Ouderlijk toezicht", "parental_control": "Ouderlijk toezicht",
"safe_browsing": "Veilig browsen", "safe_browsing": "Veilig browsen",
"served_from_cache": "{{value}} <i>(geleverd vanuit cache)</i>", "served_from_cache": "{{value}} <i>(geleverd vanuit cache)</i>",
"form_error_password_length": "Wachtwoord moet minimaal {{value}} tekens lang zijn" "form_error_password_length": "Wachtwoord moet minimaal {{value}} tekens lang zijn",
"anonymizer_notification": "<0>Opmerking:</0> IP-anonimisering is ingeschakeld. Je kunt het uitschakelen in <1>Algemene instellingen</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Kontrola rodzicielska", "parental_control": "Kontrola rodzicielska",
"safe_browsing": "Bezpieczne przeglądanie", "safe_browsing": "Bezpieczne przeglądanie",
"served_from_cache": "{{value}} <i>(podawane z pamięci podręcznej)</i>", "served_from_cache": "{{value}} <i>(podawane z pamięci podręcznej)</i>",
"form_error_password_length": "Hasło musi mieć co najmniej {{value}} znaków" "form_error_password_length": "Hasło musi mieć co najmniej {{value}} znaków",
"anonymizer_notification": "<0>Uwaga:</0> Anonimizacja IP jest włączona. Możesz ją wyłączyć w <1>Ustawieniach ogólnych</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Controle parental", "parental_control": "Controle parental",
"safe_browsing": "Navegação segura", "safe_browsing": "Navegação segura",
"served_from_cache": "{{value}} <i>(servido do cache)</i>", "served_from_cache": "{{value}} <i>(servido do cache)</i>",
"form_error_password_length": "A senha deve ter pelo menos {{value}} caracteres" "form_error_password_length": "A senha deve ter pelo menos {{value}} caracteres",
"anonymizer_notification": "<0>Observação:</0> A anonimização de IP está ativada. Você pode desativá-lo em <1>Configurações gerais</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Controlo parental", "parental_control": "Controlo parental",
"safe_browsing": "Navegação segura", "safe_browsing": "Navegação segura",
"served_from_cache": "{{value}} <i>(servido do cache)</i>", "served_from_cache": "{{value}} <i>(servido do cache)</i>",
"form_error_password_length": "A palavra-passe deve ter pelo menos {{value}} caracteres" "form_error_password_length": "A palavra-passe deve ter pelo menos {{value}} caracteres",
"anonymizer_notification": "<0>Observação:</0> A anonimização de IP está ativada. Você pode desativá-la em <1>Definições gerais</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Control Parental", "parental_control": "Control Parental",
"safe_browsing": "Navigare în siguranță", "safe_browsing": "Navigare în siguranță",
"served_from_cache": "{{value}} <i>(furnizat din cache)</i>", "served_from_cache": "{{value}} <i>(furnizat din cache)</i>",
"form_error_password_length": "Parola trebuie să aibă cel puțin {{value}} caractere" "form_error_password_length": "Parola trebuie să aibă cel puțin {{value}} caractere",
"anonymizer_notification": "<0>Nota:</0> Anonimizarea IP este activată. Puteți să o dezactivați în <1>Setări generale</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Родительский контроль", "parental_control": "Родительский контроль",
"safe_browsing": "Безопасный интернет", "safe_browsing": "Безопасный интернет",
"served_from_cache": "{{value}} <i>(получено из кеша)</i>", "served_from_cache": "{{value}} <i>(получено из кеша)</i>",
"form_error_password_length": "Пароль должен быть длиной не меньше {{value}} символов" "form_error_password_length": "Пароль должен быть длиной не меньше {{value}} символов",
"anonymizer_notification": "<0>Внимание:</0> включена анонимизация IP-адресов. Вы можете отключить её в разделе <1>Основные настройки</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Rodičovská kontrola", "parental_control": "Rodičovská kontrola",
"safe_browsing": "Bezpečné prehliadanie", "safe_browsing": "Bezpečné prehliadanie",
"served_from_cache": "{{value}} <i>(prevzatá z cache pamäte)</i>", "served_from_cache": "{{value}} <i>(prevzatá z cache pamäte)</i>",
"form_error_password_length": "Heslo musí mať dĺžku aspoň {{value}} znakov" "form_error_password_length": "Heslo musí mať dĺžku aspoň {{value}} znakov",
"anonymizer_notification": "<0>Poznámka:</0> Anonymizácia IP je zapnutá. Môžete ju vypnúť vo <1>Všeobecných nastaveniach</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Starševski nadzor", "parental_control": "Starševski nadzor",
"safe_browsing": "Varno brskanje", "safe_browsing": "Varno brskanje",
"served_from_cache": "{{value}} <i>(postreženo iz predpomnilnika)</i>", "served_from_cache": "{{value}} <i>(postreženo iz predpomnilnika)</i>",
"form_error_password_length": "Geslo mora vsebovati najmanj {{value}} znakov" "form_error_password_length": "Geslo mora vsebovati najmanj {{value}} znakov",
"anonymizer_notification": "<0>Opomba:</0> Anonimizacija IP je omogočena. Onemogočite ga lahko v <1>Splošnih nastavitvah</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Roditeljska kontrola", "parental_control": "Roditeljska kontrola",
"safe_browsing": "Sigurno pregledanje", "safe_browsing": "Sigurno pregledanje",
"served_from_cache": "{{value}} <i>(posluženo iz predmemorije)</i>", "served_from_cache": "{{value}} <i>(posluženo iz predmemorije)</i>",
"form_error_password_length": "Lozinka mora imati najmanje {{value}} znakova" "form_error_password_length": "Lozinka mora imati najmanje {{value}} znakova",
"anonymizer_notification": "<0>Nota:</0> IP prepoznavanje je omogućeno. Možete ga onemogućiti u opštim <1>postavkama</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Föräldrakontroll", "parental_control": "Föräldrakontroll",
"safe_browsing": "Säker surfning", "safe_browsing": "Säker surfning",
"served_from_cache": "{{value}} <i>(levereras från cache)</i>", "served_from_cache": "{{value}} <i>(levereras från cache)</i>",
"form_error_password_length": "Lösenordet måste vara minst {{value}} tecken långt" "form_error_password_length": "Lösenordet måste vara minst {{value}} tecken långt",
"anonymizer_notification": "<0>Observera:</0> IP-anonymisering är aktiverad. Du kan inaktivera den i <1>Allmänna inställningar</1>."
} }

View File

@@ -368,7 +368,7 @@
"encryption_server_enter": "Alan adınızı girin", "encryption_server_enter": "Alan adınızı girin",
"encryption_server_desc": "Ayarlanırsa, AdGuard Home ClientID'leri algılar, DDR sorgularına yanıt verir ve ek bağlantı doğrulamaları gerçekleştirir. Ayarlanmazsa, bu özellikler devre dışı bırakılır. Sertifikadaki DNS Adlarından biriyle eşleşmelidir.", "encryption_server_desc": "Ayarlanırsa, AdGuard Home ClientID'leri algılar, DDR sorgularına yanıt verir ve ek bağlantı doğrulamaları gerçekleştirir. Ayarlanmazsa, bu özellikler devre dışı bırakılır. Sertifikadaki DNS Adlarından biriyle eşleşmelidir.",
"encryption_redirect": "Otomatik olarak HTTPS'e yönlendir", "encryption_redirect": "Otomatik olarak HTTPS'e yönlendir",
"encryption_redirect_desc": "Etkinleştirirseniz, AdGuard Home sizi HTTP adresi yerine HTTPS adresine yönlendirir.", "encryption_redirect_desc": "İşaretlenirse, AdGuard Home sizi otomatik olarak HTTP adresinden HTTPS adreslerine yönlendirecektir.",
"encryption_https": "HTTPS bağlantı noktası", "encryption_https": "HTTPS bağlantı noktası",
"encryption_https_desc": "HTTPS bağlantı noktası yapılandırılırsa, AdGuard Home yönetici arayüzüne HTTPS aracılığıyla erişilebilir olacak ve ayrıca '/dns-query' üzerinden DNS-over-HTTPS bağlantısı sağlayacaktır.", "encryption_https_desc": "HTTPS bağlantı noktası yapılandırılırsa, AdGuard Home yönetici arayüzüne HTTPS aracılığıyla erişilebilir olacak ve ayrıca '/dns-query' üzerinden DNS-over-HTTPS bağlantısı sağlayacaktır.",
"encryption_dot": "DNS-over-TLS bağlantı noktası", "encryption_dot": "DNS-over-TLS bağlantı noktası",
@@ -408,7 +408,7 @@
"fix": "Düzelt", "fix": "Düzelt",
"dns_providers": "Aralarından seçim yapabileceğiniz, bilinen <0>DNS sağlayıcıların listesi</0>.", "dns_providers": "Aralarından seçim yapabileceğiniz, bilinen <0>DNS sağlayıcıların listesi</0>.",
"update_now": "Şimdi güncelle", "update_now": "Şimdi güncelle",
"update_failed": "Otomatik güncelleme başarısız oldu. Elle güncellemek için lütfen <a>bu adımları uygulayın</a>.", "update_failed": "Otomatik güncelleme başarısız oldu. Elle güncellemek için lütfen <a>bu adımları izleyin</a>.",
"manual_update": "Elle güncellemek için lütfen <a>bu adımları uygulayın</a>.", "manual_update": "Elle güncellemek için lütfen <a>bu adımları uygulayın</a>.",
"processing_update": "Lütfen bekleyin, AdGuard Home güncelleniyor", "processing_update": "Lütfen bekleyin, AdGuard Home güncelleniyor",
"clients_title": "Kalıcı istemciler", "clients_title": "Kalıcı istemciler",
@@ -635,5 +635,6 @@
"parental_control": "Ebeveyn Denetimi", "parental_control": "Ebeveyn Denetimi",
"safe_browsing": "Güvenli Gezinti", "safe_browsing": "Güvenli Gezinti",
"served_from_cache": "{{value}} <i>(önbellekten kullanıldı)</i>", "served_from_cache": "{{value}} <i>(önbellekten kullanıldı)</i>",
"form_error_password_length": "Parola en az {{value}} karakter uzunluğunda olmalıdır" "form_error_password_length": "Parola en az {{value}} karakter uzunluğunda olmalıdır",
"anonymizer_notification": "<0>Not:</0> IP anonimleştirme etkinleştirildi. Bunu <1>Genel ayarlardan</1> devre dışı bırakabilirsiniz."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Батьківський контроль", "parental_control": "Батьківський контроль",
"safe_browsing": "Безпечний перегляд", "safe_browsing": "Безпечний перегляд",
"served_from_cache": "{{value}} <i>(отримано з кешу)</i>", "served_from_cache": "{{value}} <i>(отримано з кешу)</i>",
"form_error_password_length": "Пароль мусить мати принаймні {{value}} символів" "form_error_password_length": "Пароль мусить мати принаймні {{value}} символів",
"anonymizer_notification": "<0>Примітка:</0> IP-анонімізацію ввімкнено. Ви можете вимкнути його в <1>Загальні налаштування</1> ."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "Quản lý của phụ huynh", "parental_control": "Quản lý của phụ huynh",
"safe_browsing": "Duyệt web an toàn", "safe_browsing": "Duyệt web an toàn",
"served_from_cache": "{{value}} <i>(được phục vụ từ bộ nhớ cache)</i>", "served_from_cache": "{{value}} <i>(được phục vụ từ bộ nhớ cache)</i>",
"form_error_password_length": "Mật khẩu phải có ít nhất {{value}} ký tự" "form_error_password_length": "Mật khẩu phải có ít nhất {{value}} ký tự",
"anonymizer_notification": "<0> Lưu ý:</0> Tính năng ẩn danh IP được bật. Bạn có thể tắt nó trong <1> Cài đặt chung</1>."
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "家长控制", "parental_control": "家长控制",
"safe_browsing": "安全浏览", "safe_browsing": "安全浏览",
"served_from_cache": "{{value}}<i>(由缓存提供)</i>", "served_from_cache": "{{value}}<i>(由缓存提供)</i>",
"form_error_password_length": "密码必须至少有 {{value}} 个字符" "form_error_password_length": "密码必须至少有 {{value}} 个字符",
"anonymizer_notification": "<0>注意:</0> IP 匿名化已启用。您可以在<1>常规设置</1>中禁用它。"
} }

View File

@@ -635,5 +635,6 @@
"parental_control": "家長控制", "parental_control": "家長控制",
"safe_browsing": "安全瀏覽", "safe_browsing": "安全瀏覽",
"served_from_cache": "{{value}} <i>(由快取提供)</i>", "served_from_cache": "{{value}} <i>(由快取提供)</i>",
"form_error_password_length": "密碼必須為至少長 {{value}} 個字元" "form_error_password_length": "密碼必須為至少長 {{value}} 個字元",
"anonymizer_notification": "<0>注意:</0>IP 匿名化被啟用。您可在<1>一般設定</1>中禁用它。"
} }

View File

@@ -31,7 +31,9 @@ export const setRulesSuccess = createAction('SET_RULES_SUCCESS');
export const setRules = (rules) => async (dispatch) => { export const setRules = (rules) => async (dispatch) => {
dispatch(setRulesRequest()); dispatch(setRulesRequest());
try { try {
const normalizedRules = normalizeRulesTextarea(rules); const normalizedRules = {
rules: normalizeRulesTextarea(rules)?.split('\n'),
};
await apiClient.setRules(normalizedRules); await apiClient.setRules(normalizedRules);
dispatch(addSuccessToast('updated_custom_filtering_toast')); dispatch(addSuccessToast('updated_custom_filtering_toast'));
dispatch(setRulesSuccess()); dispatch(setRulesSuccess());

View File

@@ -355,7 +355,7 @@ export const changeLanguageSuccess = createAction('CHANGE_LANGUAGE_SUCCESS');
export const changeLanguage = (lang) => async (dispatch) => { export const changeLanguage = (lang) => async (dispatch) => {
dispatch(changeLanguageRequest()); dispatch(changeLanguageRequest());
try { try {
await apiClient.changeLanguage(lang); await apiClient.changeLanguage({ language: lang });
dispatch(changeLanguageSuccess()); dispatch(changeLanguageSuccess());
} catch (error) { } catch (error) {
dispatch(addErrorToast({ error })); dispatch(addErrorToast({ error }));
@@ -370,8 +370,8 @@ export const getLanguageSuccess = createAction('GET_LANGUAGE_SUCCESS');
export const getLanguage = () => async (dispatch) => { export const getLanguage = () => async (dispatch) => {
dispatch(getLanguageRequest()); dispatch(getLanguageRequest());
try { try {
const language = await apiClient.getCurrentLanguage(); const langSettings = await apiClient.getCurrentLanguage();
dispatch(getLanguageSuccess(language)); dispatch(getLanguageSuccess(langSettings.language));
} catch (error) { } catch (error) {
dispatch(addErrorToast({ error })); dispatch(addErrorToast({ error }));
dispatch(getLanguageFailure()); dispatch(getLanguageFailure());
@@ -421,7 +421,10 @@ export const findActiveDhcpFailure = createAction('FIND_ACTIVE_DHCP_FAILURE');
export const findActiveDhcp = (name) => async (dispatch, getState) => { export const findActiveDhcp = (name) => async (dispatch, getState) => {
dispatch(findActiveDhcpRequest()); dispatch(findActiveDhcpRequest());
try { try {
const activeDhcp = await apiClient.findActiveDhcp(name); const req = {
interface: name,
};
const activeDhcp = await apiClient.findActiveDhcp(req);
dispatch(findActiveDhcpSuccess(activeDhcp)); dispatch(findActiveDhcpSuccess(activeDhcp));
const { check, interface_name, interfaces } = getState().dhcp; const { check, interface_name, interfaces } = getState().dhcp;
const selectedInterface = getState().form[FORM_NAME.DHCP_INTERFACES].values.interface_name; const selectedInterface = getState().form[FORM_NAME.DHCP_INTERFACES].values.interface_name;

View File

@@ -10,11 +10,17 @@ class Api {
async makeRequest(path, method = 'POST', config) { async makeRequest(path, method = 'POST', config) {
const url = `${this.baseUrl}/${path}`; const url = `${this.baseUrl}/${path}`;
const axiosConfig = config || {};
if (method !== 'GET' && axiosConfig.data) {
axiosConfig.headers = axiosConfig.headers || {};
axiosConfig.headers['Content-Type'] = axiosConfig.headers['Content-Type'] || 'application/json';
}
try { try {
const response = await axios({ const response = await axios({
url, url,
method, method,
...config, ...axiosConfig,
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -55,7 +61,6 @@ class Api {
const { path, method } = this.GLOBAL_TEST_UPSTREAM_DNS; const { path, method } = this.GLOBAL_TEST_UPSTREAM_DNS;
const config = { const config = {
data: servers, data: servers,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }
@@ -64,7 +69,6 @@ class Api {
const { path, method } = this.GLOBAL_VERSION; const { path, method } = this.GLOBAL_VERSION;
const config = { const config = {
data, data,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }
@@ -100,7 +104,6 @@ class Api {
const { path, method } = this.FILTERING_REFRESH; const { path, method } = this.FILTERING_REFRESH;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
@@ -110,7 +113,6 @@ class Api {
const { path, method } = this.FILTERING_ADD_FILTER; const { path, method } = this.FILTERING_ADD_FILTER;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
@@ -120,7 +122,6 @@ class Api {
const { path, method } = this.FILTERING_REMOVE_FILTER; const { path, method } = this.FILTERING_REMOVE_FILTER;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
@@ -130,7 +131,6 @@ class Api {
const { path, method } = this.FILTERING_SET_RULES; const { path, method } = this.FILTERING_SET_RULES;
const parameters = { const parameters = {
data: rules, data: rules,
headers: { 'Content-Type': 'text/plain' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -139,7 +139,6 @@ class Api {
const { path, method } = this.FILTERING_CONFIG; const { path, method } = this.FILTERING_CONFIG;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -148,7 +147,6 @@ class Api {
const { path, method } = this.FILTERING_SET_URL; const { path, method } = this.FILTERING_SET_URL;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -173,12 +171,7 @@ class Api {
enableParentalControl() { enableParentalControl() {
const { path, method } = this.PARENTAL_ENABLE; const { path, method } = this.PARENTAL_ENABLE;
const parameter = 'sensitivity=TEEN'; // this parameter TEEN is hardcoded return this.makeRequest(path, method);
const config = {
data: parameter,
headers: { 'Content-Type': 'text/plain' },
};
return this.makeRequest(path, method, config);
} }
disableParentalControl() { disableParentalControl() {
@@ -240,11 +233,10 @@ class Api {
return this.makeRequest(path, method); return this.makeRequest(path, method);
} }
changeLanguage(lang) { changeLanguage(config) {
const { path, method } = this.CHANGE_LANGUAGE; const { path, method } = this.CHANGE_LANGUAGE;
const parameters = { const parameters = {
data: lang, data: config,
headers: { 'Content-Type': 'text/plain' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -280,16 +272,14 @@ class Api {
const { path, method } = this.DHCP_SET_CONFIG; const { path, method } = this.DHCP_SET_CONFIG;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
findActiveDhcp(name) { findActiveDhcp(req) {
const { path, method } = this.DHCP_FIND_ACTIVE; const { path, method } = this.DHCP_FIND_ACTIVE;
const parameters = { const parameters = {
data: name, data: req,
headers: { 'Content-Type': 'text/plain' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -298,7 +288,6 @@ class Api {
const { path, method } = this.DHCP_ADD_STATIC_LEASE; const { path, method } = this.DHCP_ADD_STATIC_LEASE;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -307,7 +296,6 @@ class Api {
const { path, method } = this.DHCP_REMOVE_STATIC_LEASE; const { path, method } = this.DHCP_REMOVE_STATIC_LEASE;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -338,7 +326,6 @@ class Api {
const { path, method } = this.INSTALL_CONFIGURE; const { path, method } = this.INSTALL_CONFIGURE;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -347,7 +334,6 @@ class Api {
const { path, method } = this.INSTALL_CHECK_CONFIG; const { path, method } = this.INSTALL_CHECK_CONFIG;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -368,7 +354,6 @@ class Api {
const { path, method } = this.TLS_CONFIG; const { path, method } = this.TLS_CONFIG;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -377,7 +362,6 @@ class Api {
const { path, method } = this.TLS_VALIDATE; const { path, method } = this.TLS_VALIDATE;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -402,7 +386,6 @@ class Api {
const { path, method } = this.ADD_CLIENT; const { path, method } = this.ADD_CLIENT;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -411,7 +394,6 @@ class Api {
const { path, method } = this.DELETE_CLIENT; const { path, method } = this.DELETE_CLIENT;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -420,7 +402,6 @@ class Api {
const { path, method } = this.UPDATE_CLIENT; const { path, method } = this.UPDATE_CLIENT;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -445,7 +426,6 @@ class Api {
const { path, method } = this.ACCESS_SET; const { path, method } = this.ACCESS_SET;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -466,7 +446,6 @@ class Api {
const { path, method } = this.REWRITE_ADD; const { path, method } = this.REWRITE_ADD;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -475,7 +454,6 @@ class Api {
const { path, method } = this.REWRITE_DELETE; const { path, method } = this.REWRITE_DELETE;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -501,7 +479,6 @@ class Api {
const { path, method } = this.BLOCKED_SERVICES_SET; const { path, method } = this.BLOCKED_SERVICES_SET;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@@ -529,7 +506,6 @@ class Api {
const { path, method } = this.STATS_CONFIG; const { path, method } = this.STATS_CONFIG;
const config = { const config = {
data, data,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }
@@ -565,7 +541,6 @@ class Api {
const { path, method } = this.QUERY_LOG_CONFIG; const { path, method } = this.QUERY_LOG_CONFIG;
const config = { const config = {
data, data,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }
@@ -582,7 +557,6 @@ class Api {
const { path, method } = this.LOGIN; const { path, method } = this.LOGIN;
const config = { const config = {
data, data,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }
@@ -609,7 +583,6 @@ class Api {
const { path, method } = this.SET_DNS_CONFIG; const { path, method } = this.SET_DNS_CONFIG;
const config = { const config = {
data, data,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }

View File

@@ -62,7 +62,7 @@ const ClientCell = ({
'white-space--nowrap': isDetailed, 'white-space--nowrap': isDetailed,
}); });
const hintClass = classNames('icons mr-4 icon--24 icon--lightgray', { const hintClass = classNames('icons mr-4 icon--24 logs__question icon--lightgray', {
'my-3': isDetailed, 'my-3': isDetailed,
}); });

View File

@@ -34,7 +34,7 @@ const DomainCell = ({
'my-3': isDetailed, 'my-3': isDetailed,
}); });
const privacyIconClass = classNames('icons mx-2 icon--24 d-none d-sm-block', { const privacyIconClass = classNames('icons mx-2 icon--24 d-none d-sm-block logs__question', {
'icon--green': hasTracker, 'icon--green': hasTracker,
'icon--disabled': !hasTracker, 'icon--disabled': !hasTracker,
'my-3': isDetailed, 'my-3': isDetailed,

View File

@@ -49,6 +49,12 @@
padding-top: 1rem; padding-top: 1rem;
} }
@media (max-width: 1024px) {
.grid .key-colon, .grid .title--border {
font-weight: 600;
}
}
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.grid { .grid {
grid-template-columns: 35% 55%; grid-template-columns: 35% 55%;
@@ -70,10 +76,6 @@
grid-column: 2 / span 1; grid-column: 2 / span 1;
margin: 0 !important; margin: 0 !important;
} }
.grid .key-colon, .grid .title--border {
font-weight: 600;
}
} }
.grid .key-colon:nth-child(odd)::after { .grid .key-colon:nth-child(odd)::after {

View File

@@ -97,7 +97,7 @@ const ResponseCell = ({
return ( return (
<div className="logs__cell logs__cell--response" role="gridcell"> <div className="logs__cell logs__cell--response" role="gridcell">
<IconTooltip <IconTooltip
className={classNames('icons mr-4 icon--24 icon--lightgray', { 'my-3': isDetailed })} className={classNames('icons mr-4 icon--24 icon--lightgray logs__question', { 'my-3': isDetailed })}
columnClass='grid grid--limited' columnClass='grid grid--limited'
tooltipClass='px-5 pb-5 pt-4 mw-75 custom-tooltip__response-details' tooltipClass='px-5 pb-5 pt-4 mw-75 custom-tooltip__response-details'
contentItemClass='text-truncate key-colon o-hidden' contentItemClass='text-truncate key-colon o-hidden'

View File

@@ -485,3 +485,13 @@
.bg--green { .bg--green {
color: var(--green79); color: var(--green79);
} }
@media (max-width: 1024px) {
.logs__question {
display: none;
}
}
.logs__modal {
max-width: 720px;
}

View File

@@ -184,27 +184,34 @@ const Logs = () => {
setButtonType={setButtonType} setButtonType={setButtonType}
setModalOpened={setModalOpened} setModalOpened={setModalOpened}
/> />
<Modal portalClassName='grid' isOpen={isSmallScreen && isModalOpened} <Modal
onRequestClose={closeModal} portalClassName='grid'
style={{ isOpen={isSmallScreen && isModalOpened}
content: { onRequestClose={closeModal}
width: '100%', style={{
height: 'fit-content', content: {
left: 0, width: '100%',
top: 47, height: 'fit-content',
padding: '1rem 1.5rem 1rem', left: '50%',
}, top: 47,
overlay: { padding: '1rem 1.5rem 1rem',
backgroundColor: 'rgba(0,0,0,0.5)', maxWidth: '720px',
}, transform: 'translateX(-50%)',
}} },
overlay: {
backgroundColor: 'rgba(0,0,0,0.5)',
},
}}
> >
<svg <div className="logs__modal-wrap">
className="icon icon--24 icon-cross d-block d-md-none cursor--pointer" <svg
onClick={closeModal}> className="icon icon--24 icon-cross d-block cursor--pointer"
<use xlinkHref="#cross" /> onClick={closeModal}
</svg> >
{processContent(detailedDataCurrent, buttonType)} <use xlinkHref="#cross" />
</svg>
{processContent(detailedDataCurrent, buttonType)}
</div>
</Modal> </Modal>
</>; </>;

View File

@@ -0,0 +1,33 @@
// Package aghchan contains channel utilities.
package aghchan
import (
"fmt"
"time"
)
// Receive returns an error if it cannot receive a value form c before timeout
// runs out.
func Receive[T any](c <-chan T, timeout time.Duration) (v T, ok bool, err error) {
var zero T
timeoutCh := time.After(timeout)
select {
case <-timeoutCh:
// TODO(a.garipov): Consider implementing [errors.Aser] for
// os.ErrTimeout.
return zero, false, fmt.Errorf("did not receive after %s", timeout)
case v, ok = <-c:
return v, ok, nil
}
}
// MustReceive panics if it cannot receive a value form c before timeout runs
// out.
func MustReceive[T any](c <-chan T, timeout time.Duration) (v T, ok bool) {
v, ok, err := Receive(c, timeout)
if err != nil {
panic(err)
}
return v, ok
}

View File

@@ -2,13 +2,21 @@
package aghhttp package aghhttp
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"github.com/AdguardTeam/AdGuardHome/internal/version"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
) )
// HTTP scheme constants.
const (
SchemeHTTP = "http"
SchemeHTTPS = "https"
)
// RegisterFunc is the function that sets the handler to handle the URL for the // RegisterFunc is the function that sets the handler to handle the URL for the
// method. // method.
// //
@@ -25,6 +33,43 @@ func OK(w http.ResponseWriter) {
// Error writes formatted message to w and also logs it. // Error writes formatted message to w and also logs it.
func Error(r *http.Request, w http.ResponseWriter, code int, format string, args ...any) { func Error(r *http.Request, w http.ResponseWriter, code int, format string, args ...any) {
text := fmt.Sprintf(format, args...) text := fmt.Sprintf(format, args...)
log.Error("%s %s: %s", r.Method, r.URL, text) log.Error("%s %s %s: %s", r.Method, r.Host, r.URL, text)
http.Error(w, text, code) http.Error(w, text, code)
} }
// UserAgent returns the ID of the service as a User-Agent string. It can also
// be used as the value of the Server HTTP header.
func UserAgent() (ua string) {
return fmt.Sprintf("AdGuardHome/%s", version.Version())
}
// textPlainDeprMsg is the message returned to API users when they try to use
// an API that used to accept "text/plain" but doesn't anymore.
const textPlainDeprMsg = `using this api with the text/plain content-type is deprecated; ` +
`use application/json`
// WriteTextPlainDeprecated responds to the request with a message about
// deprecation and removal of a plain-text API if the request is made with the
// "text/plain" content-type.
func WriteTextPlainDeprecated(w http.ResponseWriter, r *http.Request) (isPlainText bool) {
if r.Header.Get(HdrNameContentType) != HdrValTextPlain {
return false
}
Error(r, w, http.StatusUnsupportedMediaType, textPlainDeprMsg)
return true
}
// WriteJSONResponse sets the content-type header in w.Header() to
// "application/json", encodes resp to w, calls Error on any returned error, and
// returns it as well.
func WriteJSONResponse(w http.ResponseWriter, r *http.Request, resp any) (err error) {
w.Header().Set(HdrNameContentType, HdrValApplicationJSON)
err = json.NewEncoder(w).Encode(resp)
if err != nil {
Error(r, w, http.StatusInternalServerError, "encoding resp: %s", err)
}
return err
}

View File

@@ -0,0 +1,22 @@
package aghhttp
// HTTP Headers
// HTTP header name constants.
//
// TODO(a.garipov): Remove unused.
const (
HdrNameAcceptEncoding = "Accept-Encoding"
HdrNameAccessControlAllowOrigin = "Access-Control-Allow-Origin"
HdrNameContentEncoding = "Content-Encoding"
HdrNameContentType = "Content-Type"
HdrNameServer = "Server"
HdrNameTrailer = "Trailer"
HdrNameUserAgent = "User-Agent"
)
// HTTP header value constants.
const (
HdrValApplicationJSON = "application/json"
HdrValTextPlain = "text/plain"
)

View File

@@ -10,9 +10,9 @@ import (
"testing/fstest" "testing/fstest"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghchan"
"github.com/AdguardTeam/AdGuardHome/internal/aghtest" "github.com/AdguardTeam/AdGuardHome/internal/aghtest"
"github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/netutil"
"github.com/AdguardTeam/golibs/stringutil" "github.com/AdguardTeam/golibs/stringutil"
"github.com/AdguardTeam/golibs/testutil" "github.com/AdguardTeam/golibs/testutil"
"github.com/AdguardTeam/urlfilter" "github.com/AdguardTeam/urlfilter"
@@ -163,15 +163,9 @@ func TestHostsContainer_refresh(t *testing.T) {
checkRefresh := func(t *testing.T, want *HostsRecord) { checkRefresh := func(t *testing.T, want *HostsRecord) {
t.Helper() t.Helper()
var ok bool upd, ok := aghchan.MustReceive(hc.Upd(), 1*time.Second)
var upd *netutil.IPMap require.True(t, ok)
select { require.NotNil(t, upd)
case upd, ok = <-hc.Upd():
require.True(t, ok)
require.NotNil(t, upd)
case <-time.After(1 * time.Second):
t.Fatal("did not receive after 1s")
}
assert.Equal(t, 1, upd.Len()) assert.Equal(t, 1, upd.Len())

View File

@@ -18,27 +18,18 @@ import (
// How to test on a real Linux machine: // How to test on a real Linux machine:
// //
// 1. Run: // 1. Run "sudo ipset create example_set hash:ip family ipv4".
// //
// sudo ipset create example_set hash:ip family ipv4 // 2. Run "sudo ipset list example_set". The Members field should be empty.
// //
// 2. Run: // 3. Add the line "example.com/example_set" to your AdGuardHome.yaml.
// //
// sudo ipset list example_set // 4. Start AdGuardHome.
// //
// The Members field should be empty. // 5. Make requests to example.com and its subdomains.
// //
// 3. Add the line "example.com/example_set" to your AdGuardHome.yaml. // 6. Run "sudo ipset list example_set". The Members field should contain the
// // resolved IP addresses.
// 4. Start AdGuardHome.
//
// 5. Make requests to example.com and its subdomains.
//
// 6. Run:
//
// sudo ipset list example_set
//
// The Members field should contain the resolved IP addresses.
// newIpsetMgr returns a new Linux ipset manager. // newIpsetMgr returns a new Linux ipset manager.
func newIpsetMgr(ipsetConf []string) (set IpsetManager, err error) { func newIpsetMgr(ipsetConf []string) (set IpsetManager, err error) {

View File

@@ -1,6 +1,7 @@
package aghtest package aghtest
import ( import (
"context"
"io/fs" "io/fs"
"net" "net"
@@ -15,6 +16,8 @@ import (
// Standard Library // Standard Library
// Package fs
// type check // type check
var _ fs.FS = &FS{} var _ fs.FS = &FS{}
@@ -58,6 +61,8 @@ func (fsys *StatFS) Stat(name string) (fs.FileInfo, error) {
return fsys.OnStat(name) return fsys.OnStat(name)
} }
// Package net
// type check // type check
var _ net.Listener = (*Listener)(nil) var _ net.Listener = (*Listener)(nil)
@@ -83,32 +88,10 @@ func (l *Listener) Close() (err error) {
return l.OnClose() return l.OnClose()
} }
// Module dnsproxy
// type check
var _ upstream.Upstream = (*UpstreamMock)(nil)
// UpstreamMock is a mock [upstream.Upstream] implementation for tests.
//
// TODO(a.garipov): Replace with all uses of Upstream with UpstreamMock and
// rename it to just Upstream.
type UpstreamMock struct {
OnAddress func() (addr string)
OnExchange func(req *dns.Msg) (resp *dns.Msg, err error)
}
// Address implements the [upstream.Upstream] interface for *UpstreamMock.
func (u *UpstreamMock) Address() (addr string) {
return u.OnAddress()
}
// Exchange implements the [upstream.Upstream] interface for *UpstreamMock.
func (u *UpstreamMock) Exchange(req *dns.Msg) (resp *dns.Msg, err error) {
return u.OnExchange(req)
}
// Module AdGuardHome // Module AdGuardHome
// Package aghos
// type check // type check
var _ aghos.FSWatcher = (*FSWatcher)(nil) var _ aghos.FSWatcher = (*FSWatcher)(nil)
@@ -133,3 +116,57 @@ func (w *FSWatcher) Add(name string) (err error) {
func (w *FSWatcher) Close() (err error) { func (w *FSWatcher) Close() (err error) {
return w.OnClose() return w.OnClose()
} }
// Package websvc
// ServiceWithConfig is a mock [websvc.ServiceWithConfig] implementation for
// tests.
type ServiceWithConfig[ConfigType any] struct {
OnStart func() (err error)
OnShutdown func(ctx context.Context) (err error)
OnConfig func() (c ConfigType)
}
// Start implements the [websvc.ServiceWithConfig] interface for
// *ServiceWithConfig.
func (s *ServiceWithConfig[_]) Start() (err error) {
return s.OnStart()
}
// Shutdown implements the [websvc.ServiceWithConfig] interface for
// *ServiceWithConfig.
func (s *ServiceWithConfig[_]) Shutdown(ctx context.Context) (err error) {
return s.OnShutdown(ctx)
}
// Config implements the [websvc.ServiceWithConfig] interface for
// *ServiceWithConfig.
func (s *ServiceWithConfig[ConfigType]) Config() (c ConfigType) {
return s.OnConfig()
}
// Module dnsproxy
// Package upstream
// type check
var _ upstream.Upstream = (*UpstreamMock)(nil)
// UpstreamMock is a mock [upstream.Upstream] implementation for tests.
//
// TODO(a.garipov): Replace with all uses of Upstream with UpstreamMock and
// rename it to just Upstream.
type UpstreamMock struct {
OnAddress func() (addr string)
OnExchange func(req *dns.Msg) (resp *dns.Msg, err error)
}
// Address implements the [upstream.Upstream] interface for *UpstreamMock.
func (u *UpstreamMock) Address() (addr string) {
return u.OnAddress()
}
// Exchange implements the [upstream.Upstream] interface for *UpstreamMock.
func (u *UpstreamMock) Exchange(req *dns.Msg) (resp *dns.Msg, err error) {
return u.OnExchange(req)
}

View File

@@ -1,9 +1,9 @@
package aghtest_test package aghtest_test
import ( import (
"github.com/AdguardTeam/AdGuardHome/internal/aghos"
"github.com/AdguardTeam/AdGuardHome/internal/aghtest" "github.com/AdguardTeam/AdGuardHome/internal/aghtest"
"github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
) )
// type check // type check
var _ aghos.FSWatcher = (*aghtest.FSWatcher)(nil) var _ websvc.ServiceWithConfig[struct{}] = (*aghtest.ServiceWithConfig[struct{}])(nil)

View File

@@ -5,11 +5,9 @@ package dhcpd
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net" "net"
"net/http" "net/http"
"os" "os"
"strings"
"github.com/AdguardTeam/AdGuardHome/internal/aghalg" "github.com/AdguardTeam/AdGuardHome/internal/aghalg"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
@@ -410,31 +408,37 @@ type dhcpSearchResult struct {
V6 dhcpSearchV6Result `json:"v6"` V6 dhcpSearchV6Result `json:"v6"`
} }
// Perform the following tasks: // findActiveServerReq is the JSON structure for the request to find active DHCP
// . Search for another DHCP server running // servers.
// . Check if a static IP is configured for the network interface type findActiveServerReq struct {
// Respond with results Interface string `json:"interface"`
}
// handleDHCPFindActiveServer performs the following tasks:
// 1. searches for another DHCP server in the network;
// 2. check if a static IP is configured for the network interface;
// 3. responds with the results.
func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Request) { func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Request) {
// This use of ReadAll is safe, because request's body is now limited. if aghhttp.WriteTextPlainDeprecated(w, r) {
body, err := io.ReadAll(r.Body) return
}
req := &findActiveServerReq{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil { if err != nil {
msg := fmt.Sprintf("failed to read request body: %s", err) aghhttp.Error(r, w, http.StatusBadRequest, "reading req: %s", err)
log.Error(msg)
http.Error(w, msg, http.StatusBadRequest)
return return
} }
ifaceName := strings.TrimSpace(string(body)) ifaceName := req.Interface
if ifaceName == "" { if ifaceName == "" {
msg := "empty interface name specified" aghhttp.Error(r, w, http.StatusBadRequest, "empty interface name")
log.Error(msg)
http.Error(w, msg, http.StatusBadRequest)
return return
} }
result := dhcpSearchResult{ result := &dhcpSearchResult{
V4: dhcpSearchV4Result{ V4: dhcpSearchV4Result{
OtherServer: dhcpSearchOtherResult{ OtherServer: dhcpSearchOtherResult{
Found: "no", Found: "no",
@@ -459,6 +463,14 @@ func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Reque
result.V4.StaticIP.IP = aghnet.GetSubnet(ifaceName).String() result.V4.StaticIP.IP = aghnet.GetSubnet(ifaceName).String()
} }
setOtherDHCPResult(ifaceName, result)
_ = aghhttp.WriteJSONResponse(w, r, result)
}
// setOtherDHCPResult sets the results of the check for another DHCP server in
// result.
func setOtherDHCPResult(ifaceName string, result *dhcpSearchResult) {
found4, found6, err4, err6 := aghnet.CheckOtherDHCP(ifaceName) found4, found6, err4, err6 := aghnet.CheckOtherDHCP(ifaceName)
if err4 != nil { if err4 != nil {
result.V4.OtherServer.Found = "error" result.V4.OtherServer.Found = "error"
@@ -466,24 +478,13 @@ func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Reque
} else if found4 { } else if found4 {
result.V4.OtherServer.Found = "yes" result.V4.OtherServer.Found = "yes"
} }
if err6 != nil { if err6 != nil {
result.V6.OtherServer.Found = "error" result.V6.OtherServer.Found = "error"
result.V6.OtherServer.Error = err6.Error() result.V6.OtherServer.Error = err6.Error()
} else if found6 { } else if found6 {
result.V6.OtherServer.Found = "yes" result.V6.OtherServer.Found = "yes"
} }
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(result)
if err != nil {
aghhttp.Error(
r,
w,
http.StatusInternalServerError,
"Failed to marshal DHCP found json: %s",
err,
)
}
} }
func (s *server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request) { func (s *server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request) {

View File

@@ -296,7 +296,7 @@ func (s *Server) makeDDRResponse(req *dns.Msg) (resp *dns.Msg) {
values := []dns.SVCBKeyValue{ values := []dns.SVCBKeyValue{
&dns.SVCBAlpn{Alpn: []string{"h2"}}, &dns.SVCBAlpn{Alpn: []string{"h2"}},
&dns.SVCBPort{Port: uint16(addr.Port)}, &dns.SVCBPort{Port: uint16(addr.Port)},
&dns.SVCBDoHPath{Template: "/dns-query?dns"}, &dns.SVCBDoHPath{Template: "/dns-query{?dns}"},
} }
ans := &dns.SVCB{ ans := &dns.SVCB{

View File

@@ -26,7 +26,7 @@ func TestServer_ProcessDDRQuery(t *testing.T) {
Value: []dns.SVCBKeyValue{ Value: []dns.SVCBKeyValue{
&dns.SVCBAlpn{Alpn: []string{"h2"}}, &dns.SVCBAlpn{Alpn: []string{"h2"}},
&dns.SVCBPort{Port: 8044}, &dns.SVCBPort{Port: 8044},
&dns.SVCBDoHPath{Template: "/dns-query?dns"}, &dns.SVCBDoHPath{Template: "/dns-query{?dns}"},
}, },
} }

View File

@@ -67,10 +67,11 @@ func createTestServer(
ID: 0, Data: []byte(rules), ID: 0, Data: []byte(rules),
}} }}
f := filtering.New(filterConf, filters) f, err := filtering.New(filterConf, filters)
require.NoError(t, err)
f.SetEnabled(true) f.SetEnabled(true)
var err error
s, err = NewServer(DNSCreateParams{ s, err = NewServer(DNSCreateParams{
DHCPServer: testDHCP, DHCPServer: testDHCP,
DNSFilter: f, DNSFilter: f,
@@ -774,7 +775,9 @@ func TestBlockedCustomIP(t *testing.T) {
Data: []byte(rules), Data: []byte(rules),
}} }}
f := filtering.New(&filtering.Config{}, filters) f, err := filtering.New(&filtering.Config{}, filters)
require.NoError(t, err)
s, err := NewServer(DNSCreateParams{ s, err := NewServer(DNSCreateParams{
DHCPServer: testDHCP, DHCPServer: testDHCP,
DNSFilter: f, DNSFilter: f,
@@ -906,7 +909,9 @@ func TestRewrite(t *testing.T) {
Type: dns.TypeCNAME, Type: dns.TypeCNAME,
}}, }},
} }
f := filtering.New(c, nil) f, err := filtering.New(c, nil)
require.NoError(t, err)
f.SetEnabled(true) f.SetEnabled(true)
s, err := NewServer(DNSCreateParams{ s, err := NewServer(DNSCreateParams{
@@ -1021,19 +1026,14 @@ var testDHCP = &dhcpd.MockInterface{
OnWriteDiskConfig: func(c *dhcpd.ServerConfig) { panic("not implemented") }, OnWriteDiskConfig: func(c *dhcpd.ServerConfig) { panic("not implemented") },
} }
// func (*testDHCP) Leases(flags dhcpd.GetLeasesFlags) (leases []*dhcpd.Lease) {
// return []*dhcpd.Lease{{
// IP: net.IP{192, 168, 12, 34},
// HWAddr: net.HardwareAddr{0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA},
// Hostname: "myhost",
// }}
// }
func TestPTRResponseFromDHCPLeases(t *testing.T) { func TestPTRResponseFromDHCPLeases(t *testing.T) {
const localDomain = "lan" const localDomain = "lan"
flt, err := filtering.New(&filtering.Config{}, nil)
require.NoError(t, err)
s, err := NewServer(DNSCreateParams{ s, err := NewServer(DNSCreateParams{
DNSFilter: filtering.New(&filtering.Config{}, nil), DNSFilter: flt,
DHCPServer: testDHCP, DHCPServer: testDHCP,
PrivateNets: netutil.SubnetSetFunc(netutil.IsLocallyServed), PrivateNets: netutil.SubnetSetFunc(netutil.IsLocallyServed),
LocalDomain: localDomain, LocalDomain: localDomain,
@@ -1100,9 +1100,11 @@ func TestPTRResponseFromHosts(t *testing.T) {
assert.Equal(t, uint32(1), atomic.LoadUint32(&eventsCalledCounter)) assert.Equal(t, uint32(1), atomic.LoadUint32(&eventsCalledCounter))
}) })
flt := filtering.New(&filtering.Config{ flt, err := filtering.New(&filtering.Config{
EtcHosts: hc, EtcHosts: hc,
}, nil) }, nil)
require.NoError(t, err)
flt.SetEnabled(true) flt.SetEnabled(true)
var s *Server var s *Server

View File

@@ -35,7 +35,8 @@ func TestHandleDNSRequest_filterDNSResponse(t *testing.T) {
ID: 0, Data: []byte(rules), ID: 0, Data: []byte(rules),
}} }}
f := filtering.New(&filtering.Config{}, filters) f, err := filtering.New(&filtering.Config{}, filters)
require.NoError(t, err)
f.SetEnabled(true) f.SetEnabled(true)
s, err := NewServer(DNSCreateParams{ s, err := NewServer(DNSCreateParams{

View File

@@ -421,31 +421,34 @@ func initBlockedServices() {
} }
// BlockedSvcKnown - return TRUE if a blocked service name is known // BlockedSvcKnown - return TRUE if a blocked service name is known
func BlockedSvcKnown(s string) bool { func BlockedSvcKnown(s string) (ok bool) {
_, ok := serviceRules[s] _, ok = serviceRules[s]
return ok return ok
} }
// ApplyBlockedServices - set blocked services settings for this DNS request // ApplyBlockedServices - set blocked services settings for this DNS request
func (d *DNSFilter) ApplyBlockedServices(setts *Settings, list []string, global bool) { func (d *DNSFilter) ApplyBlockedServices(setts *Settings, list []string) {
setts.ServicesRules = []ServiceEntry{} setts.ServicesRules = []ServiceEntry{}
if global { if list == nil {
d.confLock.RLock() d.confLock.RLock()
defer d.confLock.RUnlock() defer d.confLock.RUnlock()
list = d.Config.BlockedServices list = d.Config.BlockedServices
} }
for _, name := range list { for _, name := range list {
rules, ok := serviceRules[name] rules, ok := serviceRules[name]
if !ok { if !ok {
log.Error("unknown service name: %s", name) log.Error("unknown service name: %s", name)
continue continue
} }
s := ServiceEntry{} setts.ServicesRules = append(setts.ServicesRules, ServiceEntry{
s.Name = name Name: name,
s.Rules = rules Rules: rules,
setts.ServicesRules = append(setts.ServicesRules, s) })
} }
} }
@@ -490,10 +493,3 @@ func (d *DNSFilter) handleBlockedServicesSet(w http.ResponseWriter, r *http.Requ
d.ConfigModified() d.ConfigModified()
} }
// registerBlockedServicesHandlers - register HTTP handlers
func (d *DNSFilter) registerBlockedServicesHandlers() {
d.Config.HTTPRegister(http.MethodGet, "/control/blocked_services/services", d.handleBlockedServicesAvailableServices)
d.Config.HTTPRegister(http.MethodGet, "/control/blocked_services/list", d.handleBlockedServicesList)
d.Config.HTTPRegister(http.MethodPost, "/control/blocked_services/set", d.handleBlockedServicesSet)
}

View File

@@ -49,7 +49,7 @@ func TestDNSFilter_CheckHostRules_dnsrewrite(t *testing.T) {
|1.2.3.5.in-addr.arpa^$dnsrewrite=NOERROR;PTR;new-ptr-with-dot. |1.2.3.5.in-addr.arpa^$dnsrewrite=NOERROR;PTR;new-ptr-with-dot.
` `
f := newForTest(t, nil, []Filter{{ID: 0, Data: []byte(text)}}) f, _ := newForTest(t, nil, []Filter{{ID: 0, Data: []byte(text)}})
setts := &Settings{ setts := &Settings{
FilteringEnabled: true, FilteringEnabled: true,
} }

View File

@@ -1,4 +1,4 @@
package home package filtering
import ( import (
"bufio" "bufio"
@@ -8,63 +8,29 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"sync/atomic"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/filtering"
"github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
"github.com/AdguardTeam/golibs/stringutil" "github.com/AdguardTeam/golibs/stringutil"
"golang.org/x/exp/slices"
) )
var nextFilterID = time.Now().Unix() // semi-stable way to generate an unique ID // filterDir is the subdirectory of a data directory to store downloaded
// filters.
const filterDir = "filters"
// Filtering - module object // nextFilterID is a way to seed a unique ID generation.
type Filtering struct { //
// conf FilteringConf // TODO(e.burkov): Use more deterministic approach.
refreshStatus uint32 // 0:none; 1:in progress var nextFilterID = time.Now().Unix()
refreshLock sync.Mutex
filterTitleRegexp *regexp.Regexp
}
// Init - initialize the module // FilterYAML respresents a filter list in the configuration file.
func (f *Filtering) Init() { //
f.filterTitleRegexp = regexp.MustCompile(`^! Title: +(.*)$`) // TODO(e.burkov): Investigate if the field oredering is important.
_ = os.MkdirAll(filepath.Join(Context.getDataDir(), filterDir), 0o755) type FilterYAML struct {
f.loadFilters(config.Filters)
f.loadFilters(config.WhitelistFilters)
deduplicateFilters()
updateUniqueFilterID(config.Filters)
updateUniqueFilterID(config.WhitelistFilters)
}
// Start - start the module
func (f *Filtering) Start() {
f.RegisterFilteringHandlers()
// Here we should start updating filters,
// but currently we can't wake up the periodic task to do so.
// So for now we just start this periodic task from here.
go f.periodicallyRefreshFilters()
}
// Close - close the module
func (f *Filtering) Close() {
}
func defaultFilters() []filter {
return []filter{
{Filter: filtering.Filter{ID: 1}, Enabled: true, URL: "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt", Name: "AdGuard DNS filter"},
{Filter: filtering.Filter{ID: 2}, Enabled: false, URL: "https://adaway.org/hosts.txt", Name: "AdAway Default Blocklist"},
}
}
// field ordering is important -- yaml fields will mirror ordering from here
type filter struct {
Enabled bool Enabled bool
URL string // URL or a file path URL string // URL or a file path
Name string `yaml:"name"` Name string `yaml:"name"`
@@ -73,91 +39,108 @@ type filter struct {
checksum uint32 // checksum of the file data checksum uint32 // checksum of the file data
white bool white bool
filtering.Filter `yaml:",inline"` Filter `yaml:",inline"`
}
// Clear filter rules
func (filter *FilterYAML) unload() {
filter.RulesCount = 0
filter.checksum = 0
}
// Path to the filter contents
func (filter *FilterYAML) Path(dataDir string) string {
return filepath.Join(dataDir, filterDir, strconv.FormatInt(filter.ID, 10)+".txt")
} }
const ( const (
statusFound = 1 statusFound = 1 << iota
statusEnabledChanged = 2 statusEnabledChanged
statusURLChanged = 4 statusURLChanged
statusURLExists = 8 statusURLExists
statusUpdateRequired = 0x10 statusUpdateRequired
) )
// Update properties for a filter specified by its URL // Update properties for a filter specified by its URL
// Return status* flags. // Return status* flags.
func (f *Filtering) filterSetProperties(url string, newf filter, whitelist bool) int { func (d *DNSFilter) filterSetProperties(url string, newf FilterYAML, whitelist bool) int {
r := 0 r := 0
config.Lock() d.filtersMu.Lock()
defer config.Unlock() defer d.filtersMu.Unlock()
filters := &config.Filters filters := d.Filters
if whitelist { if whitelist {
filters = &config.WhitelistFilters filters = d.WhitelistFilters
} }
for i := range *filters { i := slices.IndexFunc(filters, func(filt FilterYAML) bool {
filt := &(*filters)[i] return filt.URL == url
if filt.URL != url { })
continue if i == -1 {
return 0
}
filt := &filters[i]
log.Debug("filter: set properties: %s: {%s %s %v}", filt.URL, newf.Name, newf.URL, newf.Enabled)
filt.Name = newf.Name
if filt.URL != newf.URL {
r |= statusURLChanged | statusUpdateRequired
if d.filterExistsNoLock(newf.URL) {
return statusURLExists
} }
log.Debug("filter: set properties: %s: {%s %s %v}", filt.URL = newf.URL
filt.URL, newf.Name, newf.URL, newf.Enabled) filt.unload()
filt.Name = newf.Name filt.LastUpdated = time.Time{}
filt.checksum = 0
filt.RulesCount = 0
}
if filt.URL != newf.URL { if filt.Enabled != newf.Enabled {
r |= statusURLChanged | statusUpdateRequired r |= statusEnabledChanged
if filterExistsNoLock(newf.URL) { filt.Enabled = newf.Enabled
return statusURLExists if filt.Enabled {
} if (r & statusURLChanged) == 0 {
filt.URL = newf.URL err := d.load(filt)
filt.unload() if err != nil {
filt.LastUpdated = time.Time{} // TODO(e.burkov): It seems the error is only returned when
filt.checksum = 0 // the file exists and couldn't be open. Investigate and
filt.RulesCount = 0 // improve.
} log.Error("loading filter %d: %s", filt.ID, err)
if filt.Enabled != newf.Enabled { filt.LastUpdated = time.Time{}
r |= statusEnabledChanged filt.checksum = 0
filt.Enabled = newf.Enabled filt.RulesCount = 0
if filt.Enabled { r |= statusUpdateRequired
if (r & statusURLChanged) == 0 {
e := f.load(filt)
if e != nil {
// This isn't a fatal error,
// because it may occur when someone removes the file from disk.
filt.LastUpdated = time.Time{}
filt.checksum = 0
filt.RulesCount = 0
r |= statusUpdateRequired
}
} }
} else {
filt.unload()
} }
} else {
filt.unload()
} }
return r | statusFound
} }
return 0
return r | statusFound
} }
// Return TRUE if a filter with this URL exists // Return TRUE if a filter with this URL exists
func filterExists(url string) bool { func (d *DNSFilter) filterExists(url string) bool {
config.RLock() d.filtersMu.RLock()
r := filterExistsNoLock(url) defer d.filtersMu.RUnlock()
config.RUnlock()
r := d.filterExistsNoLock(url)
return r return r
} }
func filterExistsNoLock(url string) bool { func (d *DNSFilter) filterExistsNoLock(url string) bool {
for _, f := range config.Filters { for _, f := range d.Filters {
if f.URL == url { if f.URL == url {
return true return true
} }
} }
for _, f := range config.WhitelistFilters { for _, f := range d.WhitelistFilters {
if f.URL == url { if f.URL == url {
return true return true
} }
@@ -167,26 +150,26 @@ func filterExistsNoLock(url string) bool {
// Add a filter // Add a filter
// Return FALSE if a filter with this URL exists // Return FALSE if a filter with this URL exists
func filterAdd(f filter) bool { func (d *DNSFilter) filterAdd(flt FilterYAML) bool {
config.Lock() d.filtersMu.Lock()
defer config.Unlock() defer d.filtersMu.Unlock()
// Check for duplicates // Check for duplicates
if filterExistsNoLock(f.URL) { if d.filterExistsNoLock(flt.URL) {
return false return false
} }
if f.white { if flt.white {
config.WhitelistFilters = append(config.WhitelistFilters, f) d.WhitelistFilters = append(d.WhitelistFilters, flt)
} else { } else {
config.Filters = append(config.Filters, f) d.Filters = append(d.Filters, flt)
} }
return true return true
} }
// Load filters from the disk // Load filters from the disk
// And if any filter has zero ID, assign a new one // And if any filter has zero ID, assign a new one
func (f *Filtering) loadFilters(array []filter) { func (d *DNSFilter) loadFilters(array []FilterYAML) {
for i := range array { for i := range array {
filter := &array[i] // otherwise we're operating on a copy filter := &array[i] // otherwise we're operating on a copy
if filter.ID == 0 { if filter.ID == 0 {
@@ -198,32 +181,30 @@ func (f *Filtering) loadFilters(array []filter) {
continue continue
} }
err := f.load(filter) err := d.load(filter)
if err != nil { if err != nil {
log.Error("Couldn't load filter %d contents due to %s", filter.ID, err) log.Error("Couldn't load filter %d contents due to %s", filter.ID, err)
} }
} }
} }
func deduplicateFilters() { func deduplicateFilters(filters []FilterYAML) (deduplicated []FilterYAML) {
// Deduplicate filters urls := stringutil.NewSet()
i := 0 // output index, used for deletion later lastIdx := 0
urls := map[string]bool{}
for _, filter := range config.Filters { for _, filter := range filters {
if _, ok := urls[filter.URL]; !ok { if !urls.Has(filter.URL) {
// we didn't see it before, keep it urls.Add(filter.URL)
urls[filter.URL] = true // remember the URL filters[lastIdx] = filter
config.Filters[i] = filter lastIdx++
i++
} }
} }
// all entries we want to keep are at front, delete the rest return filters[:lastIdx]
config.Filters = config.Filters[:i]
} }
// Set the next filter ID to max(filter.ID) + 1 // Set the next filter ID to max(filter.ID) + 1
func updateUniqueFilterID(filters []filter) { func updateUniqueFilterID(filters []FilterYAML) {
for _, filter := range filters { for _, filter := range filters {
if nextFilterID < filter.ID { if nextFilterID < filter.ID {
nextFilterID = filter.ID + 1 nextFilterID = filter.ID + 1
@@ -238,22 +219,19 @@ func assignUniqueFilterID() int64 {
} }
// Sets up a timer that will be checking for filters updates periodically // Sets up a timer that will be checking for filters updates periodically
func (f *Filtering) periodicallyRefreshFilters() { func (d *DNSFilter) periodicallyRefreshFilters() {
const maxInterval = 1 * 60 * 60 const maxInterval = 1 * 60 * 60
intval := 5 // use a dynamically increasing time interval intval := 5 // use a dynamically increasing time interval
for { for {
isNetworkErr := false isNetErr, ok := false, false
if config.DNS.FiltersUpdateIntervalHours != 0 && atomic.CompareAndSwapUint32(&f.refreshStatus, 0, 1) { if d.FiltersUpdateIntervalHours != 0 {
f.refreshLock.Lock() _, isNetErr, ok = d.tryRefreshFilters(true, true, false)
_, isNetworkErr = f.refreshFiltersIfNecessary(filterRefreshBlocklists | filterRefreshAllowlists) if ok && !isNetErr {
f.refreshLock.Unlock()
f.refreshStatus = 0
if !isNetworkErr {
intval = maxInterval intval = maxInterval
} }
} }
if isNetworkErr { if isNetErr {
intval *= 2 intval *= 2
if intval > maxInterval { if intval > maxInterval {
intval = maxInterval intval = maxInterval
@@ -264,51 +242,73 @@ func (f *Filtering) periodicallyRefreshFilters() {
} }
} }
// Refresh filters // tryRefreshFilters is like [refreshFilters], but backs down if the update is
// flags: filterRefresh* // already going on.
// important:
// //
// TRUE: ignore the fact that we're currently updating the filters // TODO(e.burkov): Get rid of the concurrency pattern which requires the
func (f *Filtering) refreshFilters(flags int, important bool) (int, error) { // sync.Mutex.TryLock.
set := atomic.CompareAndSwapUint32(&f.refreshStatus, 0, 1) func (d *DNSFilter) tryRefreshFilters(block, allow, force bool) (updated int, isNetworkErr, ok bool) {
if !important && !set { if ok = d.refreshLock.TryLock(); !ok {
return 0, fmt.Errorf("filters update procedure is already running") return 0, false, ok
} }
defer d.refreshLock.Unlock()
f.refreshLock.Lock() updated, isNetworkErr = d.refreshFiltersIntl(block, allow, force)
nUpdated, _ := f.refreshFiltersIfNecessary(flags)
f.refreshLock.Unlock() return updated, isNetworkErr, ok
f.refreshStatus = 0
return nUpdated, nil
} }
func (f *Filtering) refreshFiltersArray(filters *[]filter, force bool) (int, []filter, []bool, bool) { // refreshFilters updates the lists and returns the number of updated ones.
var updateFilters []filter // It's safe for concurrent use, but blocks at least until the previous
// refreshing is finished.
func (d *DNSFilter) refreshFilters(block, allow, force bool) (updated int) {
d.refreshLock.Lock()
defer d.refreshLock.Unlock()
updated, _ = d.refreshFiltersIntl(block, allow, force)
return updated
}
// listsToUpdate returns the slice of filter lists that could be updated.
func (d *DNSFilter) listsToUpdate(filters *[]FilterYAML, force bool) (toUpd []FilterYAML) {
now := time.Now()
d.filtersMu.RLock()
defer d.filtersMu.RUnlock()
for i := range *filters {
flt := &(*filters)[i] // otherwise we will be operating on a copy
log.Debug("checking list at index %d: %v", i, flt)
if !flt.Enabled {
continue
}
if !force {
exp := flt.LastUpdated.Add(time.Duration(d.FiltersUpdateIntervalHours) * time.Hour)
if now.Before(exp) {
continue
}
}
toUpd = append(toUpd, FilterYAML{
Filter: Filter{
ID: flt.ID,
},
URL: flt.URL,
Name: flt.Name,
checksum: flt.checksum,
})
}
return toUpd
}
func (d *DNSFilter) refreshFiltersArray(filters *[]FilterYAML, force bool) (int, []FilterYAML, []bool, bool) {
var updateFlags []bool // 'true' if filter data has changed var updateFlags []bool // 'true' if filter data has changed
now := time.Now() updateFilters := d.listsToUpdate(filters, force)
config.RLock()
for i := range *filters {
f := &(*filters)[i] // otherwise we will be operating on a copy
if !f.Enabled {
continue
}
expireTime := f.LastUpdated.Unix() + int64(config.DNS.FiltersUpdateIntervalHours)*60*60
if !force && expireTime > now.Unix() {
continue
}
var uf filter
uf.ID = f.ID
uf.URL = f.URL
uf.Name = f.Name
uf.checksum = f.checksum
updateFilters = append(updateFilters, uf)
}
config.RUnlock()
if len(updateFilters) == 0 { if len(updateFilters) == 0 {
return 0, nil, nil, false return 0, nil, nil, false
} }
@@ -316,7 +316,7 @@ func (f *Filtering) refreshFiltersArray(filters *[]filter, force bool) (int, []f
nfail := 0 nfail := 0
for i := range updateFilters { for i := range updateFilters {
uf := &updateFilters[i] uf := &updateFilters[i]
updated, err := f.update(uf) updated, err := d.update(uf)
updateFlags = append(updateFlags, updated) updateFlags = append(updateFlags, updated)
if err != nil { if err != nil {
nfail++ nfail++
@@ -334,7 +334,7 @@ func (f *Filtering) refreshFiltersArray(filters *[]filter, force bool) (int, []f
uf := &updateFilters[i] uf := &updateFilters[i]
updated := updateFlags[i] updated := updateFlags[i]
config.Lock() d.filtersMu.Lock()
for k := range *filters { for k := range *filters {
f := &(*filters)[k] f := &(*filters)[k]
if f.ID != uf.ID || f.URL != uf.URL { if f.ID != uf.ID || f.URL != uf.URL {
@@ -352,20 +352,14 @@ func (f *Filtering) refreshFiltersArray(filters *[]filter, force bool) (int, []f
f.checksum = uf.checksum f.checksum = uf.checksum
updateCount++ updateCount++
} }
config.Unlock() d.filtersMu.Unlock()
} }
return updateCount, updateFilters, updateFlags, false return updateCount, updateFilters, updateFlags, false
} }
const ( // refreshFiltersIntl checks filters and updates them if necessary. If force is
filterRefreshForce = 1 // ignore last file modification date // true, it ignores the filter.LastUpdated field value.
filterRefreshAllowlists = 2 // update allow-lists
filterRefreshBlocklists = 4 // update block-lists
)
// refreshFiltersIfNecessary checks filters and updates them if necessary. If
// force is true, it ignores the filter.LastUpdated field value.
// //
// Algorithm: // Algorithm:
// //
@@ -378,53 +372,49 @@ const (
// that this method works only on Unix systems. On Windows, don't pass // that this method works only on Unix systems. On Windows, don't pass
// files to filtering, pass the whole data. // files to filtering, pass the whole data.
// //
// refreshFiltersIfNecessary returns the number of updated filters. It also // refreshFiltersIntl returns the number of updated filters. It also returns
// returns true if there was a network error and nothing could be updated. // true if there was a network error and nothing could be updated.
// //
// TODO(a.garipov, e.burkov): What the hell? // TODO(a.garipov, e.burkov): What the hell?
func (f *Filtering) refreshFiltersIfNecessary(flags int) (int, bool) { func (d *DNSFilter) refreshFiltersIntl(block, allow, force bool) (int, bool) {
log.Debug("Filters: updating...") log.Debug("filtering: updating...")
updateCount := 0 updNum := 0
var updateFilters []filter var lists []FilterYAML
var updateFlags []bool var toUpd []bool
netError := false isNetErr := false
netErrorW := false
force := false if block {
if (flags & filterRefreshForce) != 0 { updNum, lists, toUpd, isNetErr = d.refreshFiltersArray(&d.Filters, force)
force = true
} }
if (flags & filterRefreshBlocklists) != 0 { if allow {
updateCount, updateFilters, updateFlags, netError = f.refreshFiltersArray(&config.Filters, force) updNumAl, listsAl, toUpdAl, isNetErrAl := d.refreshFiltersArray(&d.WhitelistFilters, force)
updNum += updNumAl
lists = append(lists, listsAl...)
toUpd = append(toUpd, toUpdAl...)
isNetErr = isNetErr || isNetErrAl
} }
if (flags & filterRefreshAllowlists) != 0 { if isNetErr {
updateCountW := 0
var updateFiltersW []filter
var updateFlagsW []bool
updateCountW, updateFiltersW, updateFlagsW, netErrorW = f.refreshFiltersArray(&config.WhitelistFilters, force)
updateCount += updateCountW
updateFilters = append(updateFilters, updateFiltersW...)
updateFlags = append(updateFlags, updateFlagsW...)
}
if netError && netErrorW {
return 0, true return 0, true
} }
if updateCount != 0 { if updNum != 0 {
enableFilters(false) d.EnableFilters(false)
for i := range updateFilters { for i := range lists {
uf := &updateFilters[i] uf := &lists[i]
updated := updateFlags[i] updated := toUpd[i]
if !updated { if !updated {
continue continue
} }
_ = os.Remove(uf.Path() + ".old") _ = os.Remove(uf.Path(d.DataDir) + ".old")
} }
} }
log.Debug("Filters: update finished") log.Debug("filtering: update finished")
return updateCount, false
return updNum, false
} }
// Allows printable UTF-8 text with CR, LF, TAB characters // Allows printable UTF-8 text with CR, LF, TAB characters
@@ -440,7 +430,7 @@ func isPrintableText(data []byte, len int) bool {
} }
// A helper function that parses filter contents and returns a number of rules and a filter name (if there's any) // A helper function that parses filter contents and returns a number of rules and a filter name (if there's any)
func (f *Filtering) parseFilterContents(file io.Reader) (int, uint32, string) { func (d *DNSFilter) parseFilterContents(file io.Reader) (int, uint32, string) {
rulesCount := 0 rulesCount := 0
name := "" name := ""
seenTitle := false seenTitle := false
@@ -455,7 +445,7 @@ func (f *Filtering) parseFilterContents(file io.Reader) (int, uint32, string) {
if len(line) == 0 { if len(line) == 0 {
// //
} else if line[0] == '!' { } else if line[0] == '!' {
m := f.filterTitleRegexp.FindAllStringSubmatch(line, -1) m := d.filterTitleRegexp.FindAllStringSubmatch(line, -1)
if len(m) > 0 && len(m[0]) >= 2 && !seenTitle { if len(m) > 0 && len(m[0]) >= 2 && !seenTitle {
name = m[0][1] name = m[0][1]
seenTitle = true seenTitle = true
@@ -476,11 +466,11 @@ func (f *Filtering) parseFilterContents(file io.Reader) (int, uint32, string) {
} }
// Perform upgrade on a filter and update LastUpdated value // Perform upgrade on a filter and update LastUpdated value
func (f *Filtering) update(filter *filter) (bool, error) { func (d *DNSFilter) update(filter *FilterYAML) (bool, error) {
b, err := f.updateIntl(filter) b, err := d.updateIntl(filter)
filter.LastUpdated = time.Now() filter.LastUpdated = time.Now()
if !b { if !b {
e := os.Chtimes(filter.Path(), filter.LastUpdated, filter.LastUpdated) e := os.Chtimes(filter.Path(d.DataDir), filter.LastUpdated, filter.LastUpdated)
if e != nil { if e != nil {
log.Error("os.Chtimes(): %v", e) log.Error("os.Chtimes(): %v", e)
} }
@@ -488,7 +478,7 @@ func (f *Filtering) update(filter *filter) (bool, error) {
return b, err return b, err
} }
func (f *Filtering) read(reader io.Reader, tmpFile *os.File, filter *filter) (int, error) { func (d *DNSFilter) read(reader io.Reader, tmpFile *os.File, filter *FilterYAML) (int, error) {
htmlTest := true htmlTest := true
firstChunk := make([]byte, 4*1024) firstChunk := make([]byte, 4*1024)
firstChunkLen := 0 firstChunkLen := 0
@@ -539,20 +529,20 @@ func (f *Filtering) read(reader io.Reader, tmpFile *os.File, filter *filter) (in
// finalizeUpdate closes and gets rid of temporary file f with filter's content // finalizeUpdate closes and gets rid of temporary file f with filter's content
// according to updated. It also saves new values of flt's name, rules number // according to updated. It also saves new values of flt's name, rules number
// and checksum if sucсeeded. // and checksum if sucсeeded.
func finalizeUpdate( func (d *DNSFilter) finalizeUpdate(
f *os.File, file *os.File,
flt *filter, flt *FilterYAML,
updated bool, updated bool,
name string, name string,
rnum int, rnum int,
cs uint32, cs uint32,
) (err error) { ) (err error) {
tmpFileName := f.Name() tmpFileName := file.Name()
// Close the file before renaming it because it's required on Windows. // Close the file before renaming it because it's required on Windows.
// //
// See https://github.com/adguardTeam/adGuardHome/issues/1553. // See https://github.com/adguardTeam/adGuardHome/issues/1553.
if err = f.Close(); err != nil { if err = file.Close(); err != nil {
return fmt.Errorf("closing temporary file: %w", err) return fmt.Errorf("closing temporary file: %w", err)
} }
@@ -562,9 +552,9 @@ func finalizeUpdate(
return os.Remove(tmpFileName) return os.Remove(tmpFileName)
} }
log.Printf("saving filter %d contents to: %s", flt.ID, flt.Path()) log.Printf("saving filter %d contents to: %s", flt.ID, flt.Path(d.DataDir))
if err = os.Rename(tmpFileName, flt.Path()); err != nil { if err = os.Rename(tmpFileName, flt.Path(d.DataDir)); err != nil {
return errors.WithDeferred(err, os.Remove(tmpFileName)) return errors.WithDeferred(err, os.Remove(tmpFileName))
} }
@@ -578,12 +568,12 @@ func finalizeUpdate(
// processUpdate copies filter's content from src to dst and returns the name, // processUpdate copies filter's content from src to dst and returns the name,
// rules number, and checksum for it. It also returns the number of bytes read // rules number, and checksum for it. It also returns the number of bytes read
// from src. // from src.
func (f *Filtering) processUpdate( func (d *DNSFilter) processUpdate(
src io.Reader, src io.Reader,
dst *os.File, dst *os.File,
flt *filter, flt *FilterYAML,
) (name string, rnum int, cs uint32, n int, err error) { ) (name string, rnum int, cs uint32, n int, err error) {
if n, err = f.read(src, dst, flt); err != nil { if n, err = d.read(src, dst, flt); err != nil {
return "", 0, 0, 0, err return "", 0, 0, 0, err
} }
@@ -591,14 +581,14 @@ func (f *Filtering) processUpdate(
return "", 0, 0, 0, err return "", 0, 0, 0, err
} }
rnum, cs, name = f.parseFilterContents(dst) rnum, cs, name = d.parseFilterContents(dst)
return name, rnum, cs, n, nil return name, rnum, cs, n, nil
} }
// updateIntl updates the flt rewriting it's actual file. It returns true if // updateIntl updates the flt rewriting it's actual file. It returns true if
// the actual update has been performed. // the actual update has been performed.
func (f *Filtering) updateIntl(flt *filter) (ok bool, err error) { func (d *DNSFilter) updateIntl(flt *FilterYAML) (ok bool, err error) {
log.Tracef("downloading update for filter %d from %s", flt.ID, flt.URL) log.Tracef("downloading update for filter %d from %s", flt.ID, flt.URL)
var name string var name string
@@ -606,12 +596,12 @@ func (f *Filtering) updateIntl(flt *filter) (ok bool, err error) {
var cs uint32 var cs uint32
var tmpFile *os.File var tmpFile *os.File
tmpFile, err = os.CreateTemp(filepath.Join(Context.getDataDir(), filterDir), "") tmpFile, err = os.CreateTemp(filepath.Join(d.DataDir, filterDir), "")
if err != nil { if err != nil {
return false, err return false, err
} }
defer func() { defer func() {
err = errors.WithDeferred(err, finalizeUpdate(tmpFile, flt, ok, name, rnum, cs)) err = errors.WithDeferred(err, d.finalizeUpdate(tmpFile, flt, ok, name, rnum, cs))
ok = ok && err == nil ok = ok && err == nil
if ok { if ok {
log.Printf("updated filter %d: %d bytes, %d rules", flt.ID, n, rnum) log.Printf("updated filter %d: %d bytes, %d rules", flt.ID, n, rnum)
@@ -638,7 +628,7 @@ func (f *Filtering) updateIntl(flt *filter) (ok bool, err error) {
r = file r = file
} else { } else {
var resp *http.Response var resp *http.Response
resp, err = Context.client.Get(flt.URL) resp, err = d.HTTPClient.Get(flt.URL)
if err != nil { if err != nil {
log.Printf("requesting filter from %s, skip: %s", flt.URL, err) log.Printf("requesting filter from %s, skip: %s", flt.URL, err)
@@ -655,16 +645,16 @@ func (f *Filtering) updateIntl(flt *filter) (ok bool, err error) {
r = resp.Body r = resp.Body
} }
name, rnum, cs, n, err = f.processUpdate(r, tmpFile, flt) name, rnum, cs, n, err = d.processUpdate(r, tmpFile, flt)
return cs != flt.checksum, err return cs != flt.checksum, err
} }
// loads filter contents from the file in dataDir // loads filter contents from the file in dataDir
func (f *Filtering) load(filter *filter) (err error) { func (d *DNSFilter) load(filter *FilterYAML) (err error) {
filterFilePath := filter.Path() filterFilePath := filter.Path(d.DataDir)
log.Tracef("filtering: loading filter %d contents to: %s", filter.ID, filterFilePath) log.Tracef("filtering: loading filter %d from %s", filter.ID, filterFilePath)
file, err := os.Open(filterFilePath) file, err := os.Open(filterFilePath)
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
@@ -682,7 +672,7 @@ func (f *Filtering) load(filter *filter) (err error) {
log.Tracef("filtering: File %s, id %d, length %d", filterFilePath, filter.ID, st.Size()) log.Tracef("filtering: File %s, id %d, length %d", filterFilePath, filter.ID, st.Size())
rulesCount, checksum, _ := f.parseFilterContents(file) rulesCount, checksum, _ := d.parseFilterContents(file)
filter.RulesCount = rulesCount filter.RulesCount = rulesCount
filter.checksum = checksum filter.checksum = checksum
@@ -691,56 +681,45 @@ func (f *Filtering) load(filter *filter) (err error) {
return nil return nil
} }
// Clear filter rules func (d *DNSFilter) EnableFilters(async bool) {
func (filter *filter) unload() { d.filtersMu.RLock()
filter.RulesCount = 0 defer d.filtersMu.RUnlock()
filter.checksum = 0
d.enableFiltersLocked(async)
} }
// Path to the filter contents func (d *DNSFilter) enableFiltersLocked(async bool) {
func (filter *filter) Path() string { filters := []Filter{{
return filepath.Join(Context.getDataDir(), filterDir, strconv.FormatInt(filter.ID, 10)+".txt") ID: CustomListID,
} Data: []byte(strings.Join(d.UserRules, "\n")),
func enableFilters(async bool) {
config.RLock()
defer config.RUnlock()
enableFiltersLocked(async)
}
func enableFiltersLocked(async bool) {
filters := []filtering.Filter{{
ID: filtering.CustomListID,
Data: []byte(strings.Join(config.UserRules, "\n")),
}} }}
for _, filter := range config.Filters { for _, filter := range d.Filters {
if !filter.Enabled { if !filter.Enabled {
continue continue
} }
filters = append(filters, filtering.Filter{ filters = append(filters, Filter{
ID: filter.ID, ID: filter.ID,
FilePath: filter.Path(), FilePath: filter.Path(d.DataDir),
}) })
} }
var allowFilters []filtering.Filter var allowFilters []Filter
for _, filter := range config.WhitelistFilters { for _, filter := range d.WhitelistFilters {
if !filter.Enabled { if !filter.Enabled {
continue continue
} }
allowFilters = append(allowFilters, filtering.Filter{ allowFilters = append(allowFilters, Filter{
ID: filter.ID, ID: filter.ID,
FilePath: filter.Path(), FilePath: filter.Path(d.DataDir),
}) })
} }
if err := Context.dnsFilter.SetFilters(filters, allowFilters, async); err != nil { if err := d.SetFilters(filters, allowFilters, async); err != nil {
log.Debug("enabling filters: %s", err) log.Debug("enabling filters: %s", err)
} }
Context.dnsFilter.SetEnabled(config.DNS.FilteringEnabled) d.SetEnabled(d.FilteringEnabled)
} }

View File

@@ -1,4 +1,4 @@
package home package filtering
import ( import (
"io/fs" "io/fs"
@@ -51,15 +51,17 @@ func TestFilters(t *testing.T) {
l := testStartFilterListener(t, &fltContent) l := testStartFilterListener(t, &fltContent)
Context = homeContext{ tempDir := t.TempDir()
workDir: t.TempDir(),
client: &http.Client{ filters, err := New(&Config{
DataDir: tempDir,
HTTPClient: &http.Client{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
}, },
} }, nil)
Context.filters.Init() require.NoError(t, err)
f := &filter{ f := &FilterYAML{
URL: (&url.URL{ URL: (&url.URL{
Scheme: "http", Scheme: "http",
Host: (&netutil.IPPort{ Host: (&netutil.IPPort{
@@ -71,21 +73,22 @@ func TestFilters(t *testing.T) {
} }
updateAndAssert := func(t *testing.T, want require.BoolAssertionFunc, wantRulesCount int) { updateAndAssert := func(t *testing.T, want require.BoolAssertionFunc, wantRulesCount int) {
ok, err := Context.filters.update(f) var ok bool
ok, err = filters.update(f)
require.NoError(t, err) require.NoError(t, err)
want(t, ok) want(t, ok)
assert.Equal(t, wantRulesCount, f.RulesCount) assert.Equal(t, wantRulesCount, f.RulesCount)
var dir []fs.DirEntry var dir []fs.DirEntry
dir, err = os.ReadDir(filepath.Join(Context.getDataDir(), filterDir)) dir, err = os.ReadDir(filepath.Join(tempDir, filterDir))
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, dir, 1) assert.Len(t, dir, 1)
require.FileExists(t, f.Path()) require.FileExists(t, f.Path(tempDir))
err = Context.filters.load(f) err = filters.load(f)
require.NoError(t, err) require.NoError(t, err)
} }
@@ -105,11 +108,9 @@ func TestFilters(t *testing.T) {
}) })
t.Run("load_unload", func(t *testing.T) { t.Run("load_unload", func(t *testing.T) {
err := Context.filters.load(f) err = filters.load(f)
require.NoError(t, err) require.NoError(t, err)
f.unload() f.unload()
}) })
require.NoError(t, os.Remove(f.Path()))
} }

View File

@@ -6,7 +6,10 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"net" "net"
"net/http"
"os" "os"
"path/filepath"
"regexp"
"runtime" "runtime"
"runtime/debug" "runtime/debug"
"strings" "strings"
@@ -24,6 +27,7 @@ import (
"github.com/AdguardTeam/urlfilter/filterlist" "github.com/AdguardTeam/urlfilter/filterlist"
"github.com/AdguardTeam/urlfilter/rules" "github.com/AdguardTeam/urlfilter/rules"
"github.com/miekg/dns" "github.com/miekg/dns"
"golang.org/x/exp/slices"
) )
// The IDs of built-in filter lists. // The IDs of built-in filter lists.
@@ -69,8 +73,13 @@ type Config struct {
// enabled is used to be returned within Settings. // enabled is used to be returned within Settings.
// //
// It is of type uint32 to be accessed by atomic. // It is of type uint32 to be accessed by atomic.
//
// TODO(e.burkov): Use atomic.Bool in Go 1.19.
enabled uint32 enabled uint32
FilteringEnabled bool `yaml:"filtering_enabled"` // whether or not use filter lists
FiltersUpdateIntervalHours uint32 `yaml:"filters_update_interval"` // time period to update filters (in hours)
ParentalEnabled bool `yaml:"parental_enabled"` ParentalEnabled bool `yaml:"parental_enabled"`
SafeSearchEnabled bool `yaml:"safesearch_enabled"` SafeSearchEnabled bool `yaml:"safesearch_enabled"`
SafeBrowsingEnabled bool `yaml:"safebrowsing_enabled"` SafeBrowsingEnabled bool `yaml:"safebrowsing_enabled"`
@@ -98,6 +107,24 @@ type Config struct {
// CustomResolver is the resolver used by DNSFilter. // CustomResolver is the resolver used by DNSFilter.
CustomResolver Resolver `yaml:"-"` CustomResolver Resolver `yaml:"-"`
// HTTPClient is the client to use for updating the remote filters.
HTTPClient *http.Client `yaml:"-"`
// DataDir is used to store filters' contents.
DataDir string `yaml:"-"`
// filtersMu protects filter lists.
filtersMu *sync.RWMutex
// Filters are the blocking filter lists.
Filters []FilterYAML `yaml:"-"`
// WhitelistFilters are the allowing filter lists.
WhitelistFilters []FilterYAML `yaml:"-"`
// UserRules is the global list of custom rules.
UserRules []string `yaml:"-"`
} }
// LookupStats store stats collected during safebrowsing or parental checks // LookupStats store stats collected during safebrowsing or parental checks
@@ -128,11 +155,13 @@ type hostChecker struct {
// DNSFilter matches hostnames and DNS requests against filtering rules. // DNSFilter matches hostnames and DNS requests against filtering rules.
type DNSFilter struct { type DNSFilter struct {
rulesStorage *filterlist.RuleStorage rulesStorage *filterlist.RuleStorage
filteringEngine *urlfilter.DNSEngine filteringEngine *urlfilter.DNSEngine
rulesStorageAllow *filterlist.RuleStorage rulesStorageAllow *filterlist.RuleStorage
filteringEngineAllow *urlfilter.DNSEngine filteringEngineAllow *urlfilter.DNSEngine
engineLock sync.RWMutex
engineLock sync.RWMutex
parentalServer string // access via methods parentalServer string // access via methods
safeBrowsingServer string // access via methods safeBrowsingServer string // access via methods
@@ -156,6 +185,12 @@ type DNSFilter struct {
// TODO(e.burkov): Use upstream that configured in dnsforward instead. // TODO(e.burkov): Use upstream that configured in dnsforward instead.
resolver Resolver resolver Resolver
refreshLock *sync.Mutex
// filterTitleRegexp is the regular expression to retrieve a name of a
// filter list.
filterTitleRegexp *regexp.Regexp
hostCheckers []hostChecker hostCheckers []hostChecker
} }
@@ -168,7 +203,7 @@ type Filter struct {
Data []byte `yaml:"-"` Data []byte `yaml:"-"`
// ID is automatically assigned when filter is added using nextFilterID. // ID is automatically assigned when filter is added using nextFilterID.
ID int64 ID int64 `yaml:"id"`
} }
// Reason holds an enum detailing why it was filtered or not filtered // Reason holds an enum detailing why it was filtered or not filtered
@@ -245,15 +280,7 @@ func (r Reason) String() string {
} }
// In returns true if reasons include r. // In returns true if reasons include r.
func (r Reason) In(reasons ...Reason) (ok bool) { func (r Reason) In(reasons ...Reason) (ok bool) { return slices.Contains(reasons, r) }
for _, reason := range reasons {
if r == reason {
return true
}
}
return false
}
// SetEnabled sets the status of the *DNSFilter. // SetEnabled sets the status of the *DNSFilter.
func (d *DNSFilter) SetEnabled(enabled bool) { func (d *DNSFilter) SetEnabled(enabled bool) {
@@ -261,6 +288,7 @@ func (d *DNSFilter) SetEnabled(enabled bool) {
if enabled { if enabled {
i = 1 i = 1
} }
atomic.StoreUint32(&d.enabled, uint32(i)) atomic.StoreUint32(&d.enabled, uint32(i))
} }
@@ -279,11 +307,20 @@ func (d *DNSFilter) GetConfig() (s Settings) {
// WriteDiskConfig - write configuration // WriteDiskConfig - write configuration
func (d *DNSFilter) WriteDiskConfig(c *Config) { func (d *DNSFilter) WriteDiskConfig(c *Config) {
d.confLock.Lock() func() {
defer d.confLock.Unlock() d.confLock.Lock()
defer d.confLock.Unlock()
*c = d.Config *c = d.Config
c.Rewrites = cloneRewrites(c.Rewrites) c.Rewrites = cloneRewrites(c.Rewrites)
}()
d.filtersMu.RLock()
defer d.filtersMu.RUnlock()
c.Filters = slices.Clone(d.Filters)
c.WhitelistFilters = slices.Clone(d.WhitelistFilters)
c.UserRules = slices.Clone(d.UserRules)
} }
// cloneRewrites returns a deep copy of entries. // cloneRewrites returns a deep copy of entries.
@@ -309,6 +346,8 @@ func (d *DNSFilter) SetFilters(blockFilters, allowFilters []Filter, async bool)
} }
d.filtersInitializerLock.Lock() // prevent multiple writers from adding more than 1 task d.filtersInitializerLock.Lock() // prevent multiple writers from adding more than 1 task
defer d.filtersInitializerLock.Unlock()
// remove all pending tasks // remove all pending tasks
stop := false stop := false
for !stop { for !stop {
@@ -321,7 +360,6 @@ func (d *DNSFilter) SetFilters(blockFilters, allowFilters []Filter, async bool)
} }
d.filtersInitializerChan <- params d.filtersInitializerChan <- params
d.filtersInitializerLock.Unlock()
return nil return nil
} }
@@ -350,22 +388,19 @@ func (d *DNSFilter) filtersInitializer() {
func (d *DNSFilter) Close() { func (d *DNSFilter) Close() {
d.engineLock.Lock() d.engineLock.Lock()
defer d.engineLock.Unlock() defer d.engineLock.Unlock()
d.reset() d.reset()
} }
func (d *DNSFilter) reset() { func (d *DNSFilter) reset() {
var err error
if d.rulesStorage != nil { if d.rulesStorage != nil {
err = d.rulesStorage.Close() if err := d.rulesStorage.Close(); err != nil {
if err != nil {
log.Error("filtering: rulesStorage.Close: %s", err) log.Error("filtering: rulesStorage.Close: %s", err)
} }
} }
if d.rulesStorageAllow != nil { if d.rulesStorageAllow != nil {
err = d.rulesStorageAllow.Close() if err := d.rulesStorageAllow.Close(); err != nil {
if err != nil {
log.Error("filtering: rulesStorageAllow.Close: %s", err) log.Error("filtering: rulesStorageAllow.Close: %s", err)
} }
} }
@@ -885,29 +920,30 @@ func InitModule() {
initBlockedServices() initBlockedServices()
} }
// New creates properly initialized DNS Filter that is ready to be used. // New creates properly initialized DNS Filter that is ready to be used. c must
func New(c *Config, blockFilters []Filter) (d *DNSFilter) { // be non-nil.
func New(c *Config, blockFilters []Filter) (d *DNSFilter, err error) {
d = &DNSFilter{ d = &DNSFilter{
resolver: net.DefaultResolver, resolver: net.DefaultResolver,
refreshLock: &sync.Mutex{},
filterTitleRegexp: regexp.MustCompile(`^! Title: +(.*)$`),
} }
if c != nil {
d.safebrowsingCache = cache.New(cache.Config{ d.safebrowsingCache = cache.New(cache.Config{
EnableLRU: true, EnableLRU: true,
MaxSize: c.SafeBrowsingCacheSize, MaxSize: c.SafeBrowsingCacheSize,
}) })
d.safeSearchCache = cache.New(cache.Config{ d.safeSearchCache = cache.New(cache.Config{
EnableLRU: true, EnableLRU: true,
MaxSize: c.SafeSearchCacheSize, MaxSize: c.SafeSearchCacheSize,
}) })
d.parentalCache = cache.New(cache.Config{ d.parentalCache = cache.New(cache.Config{
EnableLRU: true, EnableLRU: true,
MaxSize: c.ParentalCacheSize, MaxSize: c.ParentalCacheSize,
}) })
if c.CustomResolver != nil { if r := c.CustomResolver; r != nil {
d.resolver = c.CustomResolver d.resolver = r
}
} }
d.hostCheckers = []hostChecker{{ d.hostCheckers = []hostChecker{{
@@ -930,27 +966,26 @@ func New(c *Config, blockFilters []Filter) (d *DNSFilter) {
name: "safe search", name: "safe search",
}} }}
err := d.initSecurityServices() defer func() { err = errors.Annotate(err, "filtering: %w") }()
if err != nil {
log.Error("filtering: initialize services: %s", err)
return nil err = d.initSecurityServices()
if err != nil {
return nil, fmt.Errorf("initializing services: %s", err)
} }
if c != nil { d.Config = *c
d.Config = *c d.filtersMu = &sync.RWMutex{}
err = d.prepareRewrites()
if err != nil {
log.Error("rewrites: preparing: %s", err)
return nil err = d.prepareRewrites()
} if err != nil {
return nil, fmt.Errorf("rewrites: preparing: %s", err)
} }
bsvcs := []string{} bsvcs := []string{}
for _, s := range d.BlockedServices { for _, s := range d.BlockedServices {
if !BlockedSvcKnown(s) { if !BlockedSvcKnown(s) {
log.Debug("skipping unknown blocked-service %q", s) log.Debug("skipping unknown blocked-service %q", s)
continue continue
} }
bsvcs = append(bsvcs, s) bsvcs = append(bsvcs, s)
@@ -960,13 +995,24 @@ func New(c *Config, blockFilters []Filter) (d *DNSFilter) {
if blockFilters != nil { if blockFilters != nil {
err = d.initFiltering(nil, blockFilters) err = d.initFiltering(nil, blockFilters)
if err != nil { if err != nil {
log.Error("Can't initialize filtering subsystem: %s", err)
d.Close() d.Close()
return nil
return nil, fmt.Errorf("initializing filtering subsystem: %s", err)
} }
} }
return d _ = os.MkdirAll(filepath.Join(d.DataDir, filterDir), 0o755)
d.loadFilters(d.Filters)
d.loadFilters(d.WhitelistFilters)
d.Filters = deduplicateFilters(d.Filters)
d.WhitelistFilters = deduplicateFilters(d.WhitelistFilters)
updateUniqueFilterID(d.Filters)
updateUniqueFilterID(d.WhitelistFilters)
return d, nil
} }
// Start - start the module: // Start - start the module:
@@ -976,9 +1022,10 @@ func (d *DNSFilter) Start() {
d.filtersInitializerChan = make(chan filtersInitializerParams, 1) d.filtersInitializerChan = make(chan filtersInitializerParams, 1)
go d.filtersInitializer() go d.filtersInitializer()
if d.Config.HTTPRegister != nil { // for tests d.RegisterFilteringHandlers()
d.registerSecurityHandlers()
d.registerRewritesHandlers() // Here we should start updating filters,
d.registerBlockedServicesHandlers() // but currently we can't wake up the periodic task to do so.
} // So for now we just start this periodic task from here.
go d.periodicallyRefreshFilters()
} }

View File

@@ -26,10 +26,6 @@ const (
pcBlocked = "pornhub.com" pcBlocked = "pornhub.com"
) )
var setts = Settings{
ProtectionEnabled: true,
}
// Helpers. // Helpers.
func purgeCaches(d *DNSFilter) { func purgeCaches(d *DNSFilter) {
@@ -44,8 +40,8 @@ func purgeCaches(d *DNSFilter) {
} }
} }
func newForTest(t testing.TB, c *Config, filters []Filter) *DNSFilter { func newForTest(t testing.TB, c *Config, filters []Filter) (f *DNSFilter, setts *Settings) {
setts = Settings{ setts = &Settings{
ProtectionEnabled: true, ProtectionEnabled: true,
FilteringEnabled: true, FilteringEnabled: true,
} }
@@ -57,26 +53,31 @@ func newForTest(t testing.TB, c *Config, filters []Filter) *DNSFilter {
setts.SafeSearchEnabled = c.SafeSearchEnabled setts.SafeSearchEnabled = c.SafeSearchEnabled
setts.SafeBrowsingEnabled = c.SafeBrowsingEnabled setts.SafeBrowsingEnabled = c.SafeBrowsingEnabled
setts.ParentalEnabled = c.ParentalEnabled setts.ParentalEnabled = c.ParentalEnabled
} else {
// It must not be nil.
c = &Config{}
} }
d := New(c, filters) f, err := New(c, filters)
purgeCaches(d) require.NoError(t, err)
return d purgeCaches(f)
return f, setts
} }
func (d *DNSFilter) checkMatch(t *testing.T, hostname string) { func (d *DNSFilter) checkMatch(t *testing.T, hostname string, setts *Settings) {
t.Helper() t.Helper()
res, err := d.CheckHost(hostname, dns.TypeA, &setts) res, err := d.CheckHost(hostname, dns.TypeA, setts)
require.NoErrorf(t, err, "host %q", hostname) require.NoErrorf(t, err, "host %q", hostname)
assert.Truef(t, res.IsFiltered, "host %q", hostname) assert.Truef(t, res.IsFiltered, "host %q", hostname)
} }
func (d *DNSFilter) checkMatchIP(t *testing.T, hostname, ip string, qtype uint16) { func (d *DNSFilter) checkMatchIP(t *testing.T, hostname, ip string, qtype uint16, setts *Settings) {
t.Helper() t.Helper()
res, err := d.CheckHost(hostname, qtype, &setts) res, err := d.CheckHost(hostname, qtype, setts)
require.NoErrorf(t, err, "host %q", hostname, err) require.NoErrorf(t, err, "host %q", hostname, err)
require.NotEmpty(t, res.Rules, "host %q", hostname) require.NotEmpty(t, res.Rules, "host %q", hostname)
@@ -88,10 +89,10 @@ func (d *DNSFilter) checkMatchIP(t *testing.T, hostname, ip string, qtype uint16
assert.Equalf(t, ip, r.IP.String(), "host %q", hostname) assert.Equalf(t, ip, r.IP.String(), "host %q", hostname)
} }
func (d *DNSFilter) checkMatchEmpty(t *testing.T, hostname string) { func (d *DNSFilter) checkMatchEmpty(t *testing.T, hostname string, setts *Settings) {
t.Helper() t.Helper()
res, err := d.CheckHost(hostname, dns.TypeA, &setts) res, err := d.CheckHost(hostname, dns.TypeA, setts)
require.NoErrorf(t, err, "host %q", hostname) require.NoErrorf(t, err, "host %q", hostname)
assert.Falsef(t, res.IsFiltered, "host %q", hostname) assert.Falsef(t, res.IsFiltered, "host %q", hostname)
@@ -111,19 +112,19 @@ func TestEtcHostsMatching(t *testing.T) {
filters := []Filter{{ filters := []Filter{{
ID: 0, Data: []byte(text), ID: 0, Data: []byte(text),
}} }}
d := newForTest(t, nil, filters) d, setts := newForTest(t, nil, filters)
t.Cleanup(d.Close) t.Cleanup(d.Close)
d.checkMatchIP(t, "google.com", addr, dns.TypeA) d.checkMatchIP(t, "google.com", addr, dns.TypeA, setts)
d.checkMatchIP(t, "www.google.com", addr, dns.TypeA) d.checkMatchIP(t, "www.google.com", addr, dns.TypeA, setts)
d.checkMatchEmpty(t, "subdomain.google.com") d.checkMatchEmpty(t, "subdomain.google.com", setts)
d.checkMatchEmpty(t, "example.org") d.checkMatchEmpty(t, "example.org", setts)
// IPv4 match. // IPv4 match.
d.checkMatchIP(t, "block.com", "0.0.0.0", dns.TypeA) d.checkMatchIP(t, "block.com", "0.0.0.0", dns.TypeA, setts)
// Empty IPv6. // Empty IPv6.
res, err := d.CheckHost("block.com", dns.TypeAAAA, &setts) res, err := d.CheckHost("block.com", dns.TypeAAAA, setts)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, res.IsFiltered) assert.True(t, res.IsFiltered)
@@ -134,10 +135,10 @@ func TestEtcHostsMatching(t *testing.T) {
assert.Empty(t, res.Rules[0].IP) assert.Empty(t, res.Rules[0].IP)
// IPv6 match. // IPv6 match.
d.checkMatchIP(t, "ipv6.com", addr6, dns.TypeAAAA) d.checkMatchIP(t, "ipv6.com", addr6, dns.TypeAAAA, setts)
// Empty IPv4. // Empty IPv4.
res, err = d.CheckHost("ipv6.com", dns.TypeA, &setts) res, err = d.CheckHost("ipv6.com", dns.TypeA, setts)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, res.IsFiltered) assert.True(t, res.IsFiltered)
@@ -148,7 +149,7 @@ func TestEtcHostsMatching(t *testing.T) {
assert.Empty(t, res.Rules[0].IP) assert.Empty(t, res.Rules[0].IP)
// Two IPv4, both must be returned. // Two IPv4, both must be returned.
res, err = d.CheckHost("host2", dns.TypeA, &setts) res, err = d.CheckHost("host2", dns.TypeA, setts)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, res.IsFiltered) assert.True(t, res.IsFiltered)
@@ -159,7 +160,7 @@ func TestEtcHostsMatching(t *testing.T) {
assert.Equal(t, res.Rules[1].IP, net.IP{0, 0, 0, 2}) assert.Equal(t, res.Rules[1].IP, net.IP{0, 0, 0, 2})
// One IPv6 address. // One IPv6 address.
res, err = d.CheckHost("host2", dns.TypeAAAA, &setts) res, err = d.CheckHost("host2", dns.TypeAAAA, setts)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, res.IsFiltered) assert.True(t, res.IsFiltered)
@@ -176,27 +177,27 @@ func TestSafeBrowsing(t *testing.T) {
aghtest.ReplaceLogWriter(t, logOutput) aghtest.ReplaceLogWriter(t, logOutput)
aghtest.ReplaceLogLevel(t, log.DEBUG) aghtest.ReplaceLogLevel(t, log.DEBUG)
d := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil) d, setts := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true)) d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true))
d.checkMatch(t, sbBlocked) d.checkMatch(t, sbBlocked, setts)
require.Contains(t, logOutput.String(), fmt.Sprintf("safebrowsing lookup for %q", sbBlocked)) require.Contains(t, logOutput.String(), fmt.Sprintf("safebrowsing lookup for %q", sbBlocked))
d.checkMatch(t, "test."+sbBlocked) d.checkMatch(t, "test."+sbBlocked, setts)
d.checkMatchEmpty(t, "yandex.ru") d.checkMatchEmpty(t, "yandex.ru", setts)
d.checkMatchEmpty(t, pcBlocked) d.checkMatchEmpty(t, pcBlocked, setts)
// Cached result. // Cached result.
d.safeBrowsingServer = "127.0.0.1" d.safeBrowsingServer = "127.0.0.1"
d.checkMatch(t, sbBlocked) d.checkMatch(t, sbBlocked, setts)
d.checkMatchEmpty(t, pcBlocked) d.checkMatchEmpty(t, pcBlocked, setts)
d.safeBrowsingServer = defaultSafebrowsingServer d.safeBrowsingServer = defaultSafebrowsingServer
} }
func TestParallelSB(t *testing.T) { func TestParallelSB(t *testing.T) {
d := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil) d, setts := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true)) d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true))
@@ -205,10 +206,10 @@ func TestParallelSB(t *testing.T) {
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
t.Run(fmt.Sprintf("aaa%d", i), func(t *testing.T) { t.Run(fmt.Sprintf("aaa%d", i), func(t *testing.T) {
t.Parallel() t.Parallel()
d.checkMatch(t, sbBlocked) d.checkMatch(t, sbBlocked, setts)
d.checkMatch(t, "test."+sbBlocked) d.checkMatch(t, "test."+sbBlocked, setts)
d.checkMatchEmpty(t, "yandex.ru") d.checkMatchEmpty(t, "yandex.ru", setts)
d.checkMatchEmpty(t, pcBlocked) d.checkMatchEmpty(t, pcBlocked, setts)
}) })
} }
}) })
@@ -217,7 +218,7 @@ func TestParallelSB(t *testing.T) {
// Safe Search. // Safe Search.
func TestSafeSearch(t *testing.T) { func TestSafeSearch(t *testing.T) {
d := newForTest(t, &Config{SafeSearchEnabled: true}, nil) d, _ := newForTest(t, &Config{SafeSearchEnabled: true}, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
val, ok := d.SafeSearchDomain("www.google.com") val, ok := d.SafeSearchDomain("www.google.com")
require.True(t, ok) require.True(t, ok)
@@ -226,7 +227,7 @@ func TestSafeSearch(t *testing.T) {
} }
func TestCheckHostSafeSearchYandex(t *testing.T) { func TestCheckHostSafeSearchYandex(t *testing.T) {
d := newForTest(t, &Config{ d, setts := newForTest(t, &Config{
SafeSearchEnabled: true, SafeSearchEnabled: true,
}, nil) }, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
@@ -243,7 +244,7 @@ func TestCheckHostSafeSearchYandex(t *testing.T) {
"www.yandex.com", "www.yandex.com",
} { } {
t.Run(strings.ToLower(host), func(t *testing.T) { t.Run(strings.ToLower(host), func(t *testing.T) {
res, err := d.CheckHost(host, dns.TypeA, &setts) res, err := d.CheckHost(host, dns.TypeA, setts)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, res.IsFiltered) assert.True(t, res.IsFiltered)
@@ -258,7 +259,7 @@ func TestCheckHostSafeSearchYandex(t *testing.T) {
func TestCheckHostSafeSearchGoogle(t *testing.T) { func TestCheckHostSafeSearchGoogle(t *testing.T) {
resolver := &aghtest.TestResolver{} resolver := &aghtest.TestResolver{}
d := newForTest(t, &Config{ d, setts := newForTest(t, &Config{
SafeSearchEnabled: true, SafeSearchEnabled: true,
CustomResolver: resolver, CustomResolver: resolver,
}, nil) }, nil)
@@ -277,7 +278,7 @@ func TestCheckHostSafeSearchGoogle(t *testing.T) {
"www.google.je", "www.google.je",
} { } {
t.Run(host, func(t *testing.T) { t.Run(host, func(t *testing.T) {
res, err := d.CheckHost(host, dns.TypeA, &setts) res, err := d.CheckHost(host, dns.TypeA, setts)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, res.IsFiltered) assert.True(t, res.IsFiltered)
@@ -291,12 +292,12 @@ func TestCheckHostSafeSearchGoogle(t *testing.T) {
} }
func TestSafeSearchCacheYandex(t *testing.T) { func TestSafeSearchCacheYandex(t *testing.T) {
d := newForTest(t, nil, nil) d, setts := newForTest(t, nil, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
const domain = "yandex.ru" const domain = "yandex.ru"
// Check host with disabled safesearch. // Check host with disabled safesearch.
res, err := d.CheckHost(domain, dns.TypeA, &setts) res, err := d.CheckHost(domain, dns.TypeA, setts)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, res.IsFiltered) assert.False(t, res.IsFiltered)
@@ -305,10 +306,10 @@ func TestSafeSearchCacheYandex(t *testing.T) {
yandexIP := net.IPv4(213, 180, 193, 56) yandexIP := net.IPv4(213, 180, 193, 56)
d = newForTest(t, &Config{SafeSearchEnabled: true}, nil) d, setts = newForTest(t, &Config{SafeSearchEnabled: true}, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
res, err = d.CheckHost(domain, dns.TypeA, &setts) res, err = d.CheckHost(domain, dns.TypeA, setts)
require.NoError(t, err) require.NoError(t, err)
// For yandex we already know valid IP. // For yandex we already know valid IP.
@@ -325,20 +326,20 @@ func TestSafeSearchCacheYandex(t *testing.T) {
func TestSafeSearchCacheGoogle(t *testing.T) { func TestSafeSearchCacheGoogle(t *testing.T) {
resolver := &aghtest.TestResolver{} resolver := &aghtest.TestResolver{}
d := newForTest(t, &Config{ d, setts := newForTest(t, &Config{
CustomResolver: resolver, CustomResolver: resolver,
}, nil) }, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
const domain = "www.google.ru" const domain = "www.google.ru"
res, err := d.CheckHost(domain, dns.TypeA, &setts) res, err := d.CheckHost(domain, dns.TypeA, setts)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, res.IsFiltered) assert.False(t, res.IsFiltered)
require.Empty(t, res.Rules) require.Empty(t, res.Rules)
d = newForTest(t, &Config{SafeSearchEnabled: true}, nil) d, setts = newForTest(t, &Config{SafeSearchEnabled: true}, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
d.resolver = resolver d.resolver = resolver
@@ -358,7 +359,7 @@ func TestSafeSearchCacheGoogle(t *testing.T) {
} }
} }
res, err = d.CheckHost(domain, dns.TypeA, &setts) res, err = d.CheckHost(domain, dns.TypeA, setts)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, res.Rules, 1) require.Len(t, res.Rules, 1)
@@ -379,22 +380,22 @@ func TestParentalControl(t *testing.T) {
aghtest.ReplaceLogWriter(t, logOutput) aghtest.ReplaceLogWriter(t, logOutput)
aghtest.ReplaceLogLevel(t, log.DEBUG) aghtest.ReplaceLogLevel(t, log.DEBUG)
d := newForTest(t, &Config{ParentalEnabled: true}, nil) d, setts := newForTest(t, &Config{ParentalEnabled: true}, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
d.SetParentalUpstream(aghtest.NewBlockUpstream(pcBlocked, true)) d.SetParentalUpstream(aghtest.NewBlockUpstream(pcBlocked, true))
d.checkMatch(t, pcBlocked) d.checkMatch(t, pcBlocked, setts)
require.Contains(t, logOutput.String(), fmt.Sprintf("parental lookup for %q", pcBlocked)) require.Contains(t, logOutput.String(), fmt.Sprintf("parental lookup for %q", pcBlocked))
d.checkMatch(t, "www."+pcBlocked) d.checkMatch(t, "www."+pcBlocked, setts)
d.checkMatchEmpty(t, "www.yandex.ru") d.checkMatchEmpty(t, "www.yandex.ru", setts)
d.checkMatchEmpty(t, "yandex.ru") d.checkMatchEmpty(t, "yandex.ru", setts)
d.checkMatchEmpty(t, "api.jquery.com") d.checkMatchEmpty(t, "api.jquery.com", setts)
// Test cached result. // Test cached result.
d.parentalServer = "127.0.0.1" d.parentalServer = "127.0.0.1"
d.checkMatch(t, pcBlocked) d.checkMatch(t, pcBlocked, setts)
d.checkMatchEmpty(t, "yandex.ru") d.checkMatchEmpty(t, "yandex.ru", setts)
} }
// Filtering. // Filtering.
@@ -679,10 +680,10 @@ func TestMatching(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(fmt.Sprintf("%s-%s", tc.name, tc.host), func(t *testing.T) { t.Run(fmt.Sprintf("%s-%s", tc.name, tc.host), func(t *testing.T) {
filters := []Filter{{ID: 0, Data: []byte(tc.rules)}} filters := []Filter{{ID: 0, Data: []byte(tc.rules)}}
d := newForTest(t, nil, filters) d, setts := newForTest(t, nil, filters)
t.Cleanup(d.Close) t.Cleanup(d.Close)
res, err := d.CheckHost(tc.host, tc.wantDNSType, &setts) res, err := d.CheckHost(tc.host, tc.wantDNSType, setts)
require.NoError(t, err) require.NoError(t, err)
assert.Equalf(t, tc.wantIsFiltered, res.IsFiltered, "Hostname %s has wrong result (%v must be %v)", tc.host, res.IsFiltered, tc.wantIsFiltered) assert.Equalf(t, tc.wantIsFiltered, res.IsFiltered, "Hostname %s has wrong result (%v must be %v)", tc.host, res.IsFiltered, tc.wantIsFiltered)
@@ -705,7 +706,7 @@ func TestWhitelist(t *testing.T) {
whiteFilters := []Filter{{ whiteFilters := []Filter{{
ID: 0, Data: []byte(whiteRules), ID: 0, Data: []byte(whiteRules),
}} }}
d := newForTest(t, nil, filters) d, setts := newForTest(t, nil, filters)
err := d.SetFilters(filters, whiteFilters, false) err := d.SetFilters(filters, whiteFilters, false)
require.NoError(t, err) require.NoError(t, err)
@@ -713,7 +714,7 @@ func TestWhitelist(t *testing.T) {
t.Cleanup(d.Close) t.Cleanup(d.Close)
// Matched by white filter. // Matched by white filter.
res, err := d.CheckHost("host1", dns.TypeA, &setts) res, err := d.CheckHost("host1", dns.TypeA, setts)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, res.IsFiltered) assert.False(t, res.IsFiltered)
@@ -724,7 +725,7 @@ func TestWhitelist(t *testing.T) {
assert.Equal(t, "||host1^", res.Rules[0].Text) assert.Equal(t, "||host1^", res.Rules[0].Text)
// Not matched by white filter, but matched by block filter. // Not matched by white filter, but matched by block filter.
res, err = d.CheckHost("host2", dns.TypeA, &setts) res, err = d.CheckHost("host2", dns.TypeA, setts)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, res.IsFiltered) assert.True(t, res.IsFiltered)
@@ -750,7 +751,7 @@ func applyClientSettings(setts *Settings) {
} }
func TestClientSettings(t *testing.T) { func TestClientSettings(t *testing.T) {
d := newForTest(t, d, setts := newForTest(t,
&Config{ &Config{
ParentalEnabled: true, ParentalEnabled: true,
SafeBrowsingEnabled: false, SafeBrowsingEnabled: false,
@@ -796,7 +797,7 @@ func TestClientSettings(t *testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
t.Helper() t.Helper()
r, err := d.CheckHost(tc.host, dns.TypeA, &setts) r, err := d.CheckHost(tc.host, dns.TypeA, setts)
require.NoError(t, err) require.NoError(t, err)
if before { if before {
@@ -814,7 +815,7 @@ func TestClientSettings(t *testing.T) {
t.Run(tc.name, makeTester(tc, tc.before)) t.Run(tc.name, makeTester(tc, tc.before))
} }
applyClientSettings(&setts) applyClientSettings(setts)
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, makeTester(tc, !tc.before)) t.Run(tc.name, makeTester(tc, !tc.before))
@@ -824,13 +825,13 @@ func TestClientSettings(t *testing.T) {
// Benchmarks. // Benchmarks.
func BenchmarkSafeBrowsing(b *testing.B) { func BenchmarkSafeBrowsing(b *testing.B) {
d := newForTest(b, &Config{SafeBrowsingEnabled: true}, nil) d, setts := newForTest(b, &Config{SafeBrowsingEnabled: true}, nil)
b.Cleanup(d.Close) b.Cleanup(d.Close)
d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true)) d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true))
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
res, err := d.CheckHost(sbBlocked, dns.TypeA, &setts) res, err := d.CheckHost(sbBlocked, dns.TypeA, setts)
require.NoError(b, err) require.NoError(b, err)
assert.Truef(b, res.IsFiltered, "expected hostname %q to match", sbBlocked) assert.Truef(b, res.IsFiltered, "expected hostname %q to match", sbBlocked)
@@ -838,14 +839,14 @@ func BenchmarkSafeBrowsing(b *testing.B) {
} }
func BenchmarkSafeBrowsingParallel(b *testing.B) { func BenchmarkSafeBrowsingParallel(b *testing.B) {
d := newForTest(b, &Config{SafeBrowsingEnabled: true}, nil) d, setts := newForTest(b, &Config{SafeBrowsingEnabled: true}, nil)
b.Cleanup(d.Close) b.Cleanup(d.Close)
d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true)) d.SetSafeBrowsingUpstream(aghtest.NewBlockUpstream(sbBlocked, true))
b.RunParallel(func(pb *testing.PB) { b.RunParallel(func(pb *testing.PB) {
for pb.Next() { for pb.Next() {
res, err := d.CheckHost(sbBlocked, dns.TypeA, &setts) res, err := d.CheckHost(sbBlocked, dns.TypeA, setts)
require.NoError(b, err) require.NoError(b, err)
assert.Truef(b, res.IsFiltered, "expected hostname %q to match", sbBlocked) assert.Truef(b, res.IsFiltered, "expected hostname %q to match", sbBlocked)
@@ -854,7 +855,7 @@ func BenchmarkSafeBrowsingParallel(b *testing.B) {
} }
func BenchmarkSafeSearch(b *testing.B) { func BenchmarkSafeSearch(b *testing.B) {
d := newForTest(b, &Config{SafeSearchEnabled: true}, nil) d, _ := newForTest(b, &Config{SafeSearchEnabled: true}, nil)
b.Cleanup(d.Close) b.Cleanup(d.Close)
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
val, ok := d.SafeSearchDomain("www.google.com") val, ok := d.SafeSearchDomain("www.google.com")
@@ -865,7 +866,7 @@ func BenchmarkSafeSearch(b *testing.B) {
} }
func BenchmarkSafeSearchParallel(b *testing.B) { func BenchmarkSafeSearchParallel(b *testing.B) {
d := newForTest(b, &Config{SafeSearchEnabled: true}, nil) d, _ := newForTest(b, &Config{SafeSearchEnabled: true}, nil)
b.Cleanup(d.Close) b.Cleanup(d.Close)
b.RunParallel(func(pb *testing.PB) { b.RunParallel(func(pb *testing.PB) {
for pb.Next() { for pb.Next() {

View File

@@ -1,15 +1,13 @@
package home package filtering
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
@@ -34,7 +32,7 @@ func validateFilterURL(urlStr string) (err error) {
return fmt.Errorf("checking filter url: %w", err) return fmt.Errorf("checking filter url: %w", err)
} }
if s := url.Scheme; s != schemeHTTP && s != schemeHTTPS { if s := url.Scheme; s != aghhttp.SchemeHTTP && s != aghhttp.SchemeHTTPS {
return fmt.Errorf("checking filter url: invalid scheme %q", s) return fmt.Errorf("checking filter url: invalid scheme %q", s)
} }
@@ -47,7 +45,7 @@ type filterAddJSON struct {
Whitelist bool `json:"whitelist"` Whitelist bool `json:"whitelist"`
} }
func (f *Filtering) handleFilteringAddURL(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleFilteringAddURL(w http.ResponseWriter, r *http.Request) {
fj := filterAddJSON{} fj := filterAddJSON{}
err := json.NewDecoder(r.Body).Decode(&fj) err := json.NewDecoder(r.Body).Decode(&fj)
if err != nil { if err != nil {
@@ -65,14 +63,14 @@ func (f *Filtering) handleFilteringAddURL(w http.ResponseWriter, r *http.Request
} }
// Check for duplicates // Check for duplicates
if filterExists(fj.URL) { if d.filterExists(fj.URL) {
aghhttp.Error(r, w, http.StatusBadRequest, "Filter URL already added -- %s", fj.URL) aghhttp.Error(r, w, http.StatusBadRequest, "Filter URL already added -- %s", fj.URL)
return return
} }
// Set necessary properties // Set necessary properties
filt := filter{ filt := FilterYAML{
Enabled: true, Enabled: true,
URL: fj.URL, URL: fj.URL,
Name: fj.Name, Name: fj.Name,
@@ -81,7 +79,7 @@ func (f *Filtering) handleFilteringAddURL(w http.ResponseWriter, r *http.Request
filt.ID = assignUniqueFilterID() filt.ID = assignUniqueFilterID()
// Download the filter contents // Download the filter contents
ok, err := f.update(&filt) ok, err := d.update(&filt)
if err != nil { if err != nil {
aghhttp.Error( aghhttp.Error(
r, r,
@@ -109,14 +107,14 @@ func (f *Filtering) handleFilteringAddURL(w http.ResponseWriter, r *http.Request
// URL is assumed valid so append it to filters, update config, write new // URL is assumed valid so append it to filters, update config, write new
// file and reload it to engines. // file and reload it to engines.
if !filterAdd(filt) { if !d.filterAdd(filt) {
aghhttp.Error(r, w, http.StatusBadRequest, "Filter URL already added -- %s", filt.URL) aghhttp.Error(r, w, http.StatusBadRequest, "Filter URL already added -- %s", filt.URL)
return return
} }
onConfigModified() d.ConfigModified()
enableFilters(true) d.EnableFilters(true)
_, err = fmt.Fprintf(w, "OK %d rules\n", filt.RulesCount) _, err = fmt.Fprintf(w, "OK %d rules\n", filt.RulesCount)
if err != nil { if err != nil {
@@ -124,7 +122,7 @@ func (f *Filtering) handleFilteringAddURL(w http.ResponseWriter, r *http.Request
} }
} }
func (f *Filtering) handleFilteringRemoveURL(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleFilteringRemoveURL(w http.ResponseWriter, r *http.Request) {
type request struct { type request struct {
URL string `json:"url"` URL string `json:"url"`
Whitelist bool `json:"whitelist"` Whitelist bool `json:"whitelist"`
@@ -138,23 +136,23 @@ func (f *Filtering) handleFilteringRemoveURL(w http.ResponseWriter, r *http.Requ
return return
} }
config.Lock() d.filtersMu.Lock()
filters := &config.Filters filters := &d.Filters
if req.Whitelist { if req.Whitelist {
filters = &config.WhitelistFilters filters = &d.WhitelistFilters
} }
var deleted filter var deleted FilterYAML
var newFilters []filter var newFilters []FilterYAML
for _, f := range *filters { for _, flt := range *filters {
if f.URL != req.URL { if flt.URL != req.URL {
newFilters = append(newFilters, f) newFilters = append(newFilters, flt)
continue continue
} }
deleted = f deleted = flt
path := f.Path() path := flt.Path(d.DataDir)
err = os.Rename(path, path+".old") err = os.Rename(path, path+".old")
if err != nil { if err != nil {
log.Error("deleting filter %q: %s", path, err) log.Error("deleting filter %q: %s", path, err)
@@ -162,10 +160,10 @@ func (f *Filtering) handleFilteringRemoveURL(w http.ResponseWriter, r *http.Requ
} }
*filters = newFilters *filters = newFilters
config.Unlock() d.filtersMu.Unlock()
onConfigModified() d.ConfigModified()
enableFilters(true) d.EnableFilters(true)
// NOTE: The old files "filter.txt.old" aren't deleted. It's not really // NOTE: The old files "filter.txt.old" aren't deleted. It's not really
// necessary, but will require the additional complicated code to run // necessary, but will require the additional complicated code to run
@@ -191,55 +189,51 @@ type filterURLReq struct {
Whitelist bool `json:"whitelist"` Whitelist bool `json:"whitelist"`
} }
func (f *Filtering) handleFilteringSetURL(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleFilteringSetURL(w http.ResponseWriter, r *http.Request) {
fj := filterURLReq{} fj := filterURLReq{}
err := json.NewDecoder(r.Body).Decode(&fj) err := json.NewDecoder(r.Body).Decode(&fj)
if err != nil { if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "json decode: %s", err) aghhttp.Error(r, w, http.StatusBadRequest, "decoding request: %s", err)
return return
} }
if fj.Data == nil { if fj.Data == nil {
err = errors.Error("data cannot be null") aghhttp.Error(r, w, http.StatusBadRequest, "%s", errors.Error("data is absent"))
aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
return return
} }
err = validateFilterURL(fj.Data.URL) err = validateFilterURL(fj.Data.URL)
if err != nil { if err != nil {
err = fmt.Errorf("invalid url: %s", err) aghhttp.Error(r, w, http.StatusBadRequest, "invalid url: %s", err)
aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
return return
} }
filt := filter{ filt := FilterYAML{
Enabled: fj.Data.Enabled, Enabled: fj.Data.Enabled,
Name: fj.Data.Name, Name: fj.Data.Name,
URL: fj.Data.URL, URL: fj.Data.URL,
} }
status := f.filterSetProperties(fj.URL, filt, fj.Whitelist) status := d.filterSetProperties(fj.URL, filt, fj.Whitelist)
if (status & statusFound) == 0 { if (status & statusFound) == 0 {
http.Error(w, "URL doesn't exist", http.StatusBadRequest) aghhttp.Error(r, w, http.StatusBadRequest, "URL doesn't exist")
return return
} }
if (status & statusURLExists) != 0 { if (status & statusURLExists) != 0 {
http.Error(w, "URL already exists", http.StatusBadRequest) aghhttp.Error(r, w, http.StatusBadRequest, "URL already exists")
return return
} }
onConfigModified() d.ConfigModified()
restart := (status & statusEnabledChanged) != 0 restart := (status & statusEnabledChanged) != 0
if (status&statusUpdateRequired) != 0 && fj.Data.Enabled { if (status&statusUpdateRequired) != 0 && fj.Data.Enabled {
// download new filter and apply its rules // download new filter and apply its rules.
flags := filterRefreshBlocklists nUpdated := d.refreshFilters(!fj.Whitelist, fj.Whitelist, false)
if fj.Whitelist {
flags = filterRefreshAllowlists
}
nUpdated, _ := f.refreshFilters(flags, true)
// if at least 1 filter has been updated, refreshFilters() restarts the filtering automatically // if at least 1 filter has been updated, refreshFilters() restarts the filtering automatically
// if not - we restart the filtering ourselves // if not - we restart the filtering ourselves
restart = false restart = false
@@ -249,25 +243,34 @@ func (f *Filtering) handleFilteringSetURL(w http.ResponseWriter, r *http.Request
} }
if restart { if restart {
enableFilters(true) d.EnableFilters(true)
} }
} }
func (f *Filtering) handleFilteringSetRules(w http.ResponseWriter, r *http.Request) { // filteringRulesReq is the JSON structure for settings custom filtering rules.
// This use of ReadAll is safe, because request's body is now limited. type filteringRulesReq struct {
body, err := io.ReadAll(r.Body) Rules []string `json:"rules"`
}
func (d *DNSFilter) handleFilteringSetRules(w http.ResponseWriter, r *http.Request) {
if aghhttp.WriteTextPlainDeprecated(w, r) {
return
}
req := &filteringRulesReq{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil { if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "Failed to read request body: %s", err) aghhttp.Error(r, w, http.StatusBadRequest, "reading req: %s", err)
return return
} }
config.UserRules = strings.Split(string(body), "\n") d.UserRules = req.Rules
onConfigModified() d.ConfigModified()
enableFilters(true) d.EnableFilters(true)
} }
func (f *Filtering) handleFilteringRefresh(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleFilteringRefresh(w http.ResponseWriter, r *http.Request) {
type Req struct { type Req struct {
White bool `json:"whitelist"` White bool `json:"whitelist"`
} }
@@ -285,35 +288,27 @@ func (f *Filtering) handleFilteringRefresh(w http.ResponseWriter, r *http.Reques
return return
} }
flags := filterRefreshBlocklists var ok bool
if req.White { resp.Updated, _, ok = d.tryRefreshFilters(!req.White, req.White, true)
flags = filterRefreshAllowlists if !ok {
} aghhttp.Error(
func() { r,
// Temporarily unlock the Context.controlLock because the w,
// f.refreshFilters waits for it to be unlocked but it's http.StatusInternalServerError,
// actually locked in ensure wrapper. "filters update procedure is already running",
// )
// TODO(e.burkov): Reconsider this messy syncing process.
Context.controlLock.Unlock()
defer Context.controlLock.Lock()
resp.Updated, err = f.refreshFilters(flags|filterRefreshForce, false)
}()
if err != nil {
aghhttp.Error(r, w, http.StatusInternalServerError, "%s", err)
return return
} }
js, err := json.Marshal(resp) w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(resp)
if err != nil { if err != nil {
aghhttp.Error(r, w, http.StatusInternalServerError, "json encode: %s", err) aghhttp.Error(r, w, http.StatusInternalServerError, "json encode: %s", err)
return return
} }
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(js)
} }
type filterJSON struct { type filterJSON struct {
@@ -333,7 +328,7 @@ type filteringConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
func filterToJSON(f filter) filterJSON { func filterToJSON(f FilterYAML) filterJSON {
fj := filterJSON{ fj := filterJSON{
ID: f.ID, ID: f.ID,
Enabled: f.Enabled, Enabled: f.Enabled,
@@ -350,21 +345,21 @@ func filterToJSON(f filter) filterJSON {
} }
// Get filtering configuration // Get filtering configuration
func (f *Filtering) handleFilteringStatus(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleFilteringStatus(w http.ResponseWriter, r *http.Request) {
resp := filteringConfig{} resp := filteringConfig{}
config.RLock() d.filtersMu.RLock()
resp.Enabled = config.DNS.FilteringEnabled resp.Enabled = d.FilteringEnabled
resp.Interval = config.DNS.FiltersUpdateIntervalHours resp.Interval = d.FiltersUpdateIntervalHours
for _, f := range config.Filters { for _, f := range d.Filters {
fj := filterToJSON(f) fj := filterToJSON(f)
resp.Filters = append(resp.Filters, fj) resp.Filters = append(resp.Filters, fj)
} }
for _, f := range config.WhitelistFilters { for _, f := range d.WhitelistFilters {
fj := filterToJSON(f) fj := filterToJSON(f)
resp.WhitelistFilters = append(resp.WhitelistFilters, fj) resp.WhitelistFilters = append(resp.WhitelistFilters, fj)
} }
resp.UserRules = config.UserRules resp.UserRules = d.UserRules
config.RUnlock() d.filtersMu.RUnlock()
jsonVal, err := json.Marshal(resp) jsonVal, err := json.Marshal(resp)
if err != nil { if err != nil {
@@ -380,7 +375,7 @@ func (f *Filtering) handleFilteringStatus(w http.ResponseWriter, r *http.Request
} }
// Set filtering configuration // Set filtering configuration
func (f *Filtering) handleFilteringConfig(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleFilteringConfig(w http.ResponseWriter, r *http.Request) {
req := filteringConfig{} req := filteringConfig{}
err := json.NewDecoder(r.Body).Decode(&req) err := json.NewDecoder(r.Body).Decode(&req)
if err != nil { if err != nil {
@@ -389,22 +384,22 @@ func (f *Filtering) handleFilteringConfig(w http.ResponseWriter, r *http.Request
return return
} }
if !checkFiltersUpdateIntervalHours(req.Interval) { if !ValidateUpdateIvl(req.Interval) {
aghhttp.Error(r, w, http.StatusBadRequest, "Unsupported interval") aghhttp.Error(r, w, http.StatusBadRequest, "Unsupported interval")
return return
} }
func() { func() {
config.Lock() d.filtersMu.Lock()
defer config.Unlock() defer d.filtersMu.Unlock()
config.DNS.FilteringEnabled = req.Enabled d.FilteringEnabled = req.Enabled
config.DNS.FiltersUpdateIntervalHours = req.Interval d.FiltersUpdateIntervalHours = req.Interval
}() }()
onConfigModified() d.ConfigModified()
enableFilters(true) d.EnableFilters(true)
} }
type checkHostRespRule struct { type checkHostRespRule struct {
@@ -435,15 +430,15 @@ type checkHostResp struct {
FilterID int64 `json:"filter_id"` FilterID int64 `json:"filter_id"`
} }
func (f *Filtering) handleCheckHost(w http.ResponseWriter, r *http.Request) { func (d *DNSFilter) handleCheckHost(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query() host := r.URL.Query().Get("name")
host := q.Get("name")
setts := Context.dnsFilter.GetConfig() setts := d.GetConfig()
setts.FilteringEnabled = true setts.FilteringEnabled = true
setts.ProtectionEnabled = true setts.ProtectionEnabled = true
Context.dnsFilter.ApplyBlockedServices(&setts, nil, true)
result, err := Context.dnsFilter.CheckHost(host, dns.TypeA, &setts) d.ApplyBlockedServices(&setts, nil)
result, err := d.CheckHost(host, dns.TypeA, &setts)
if err != nil { if err != nil {
aghhttp.Error( aghhttp.Error(
r, r,
@@ -457,18 +452,20 @@ func (f *Filtering) handleCheckHost(w http.ResponseWriter, r *http.Request) {
return return
} }
resp := checkHostResp{} rulesLen := len(result.Rules)
resp.Reason = result.Reason.String() resp := checkHostResp{
resp.SvcName = result.ServiceName Reason: result.Reason.String(),
resp.CanonName = result.CanonName SvcName: result.ServiceName,
resp.IPList = result.IPList CanonName: result.CanonName,
IPList: result.IPList,
Rules: make([]*checkHostRespRule, len(result.Rules)),
}
if len(result.Rules) > 0 { if rulesLen > 0 {
resp.FilterID = result.Rules[0].FilterListID resp.FilterID = result.Rules[0].FilterListID
resp.Rule = result.Rules[0].Text resp.Rule = result.Rules[0].Text
} }
resp.Rules = make([]*checkHostRespRule, len(result.Rules))
for i, r := range result.Rules { for i, r := range result.Rules {
resp.Rules[i] = &checkHostRespRule{ resp.Rules[i] = &checkHostRespRule{
FilterListID: r.FilterListID, FilterListID: r.FilterListID,
@@ -476,28 +473,51 @@ func (f *Filtering) handleCheckHost(w http.ResponseWriter, r *http.Request) {
} }
} }
js, err := json.Marshal(resp)
if err != nil {
aghhttp.Error(r, w, http.StatusInternalServerError, "json encode: %s", err)
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(js) err = json.NewEncoder(w).Encode(resp)
if err != nil {
aghhttp.Error(r, w, http.StatusInternalServerError, "encoding response: %s", err)
}
} }
// RegisterFilteringHandlers - register handlers // RegisterFilteringHandlers - register handlers
func (f *Filtering) RegisterFilteringHandlers() { func (d *DNSFilter) RegisterFilteringHandlers() {
httpRegister(http.MethodGet, "/control/filtering/status", f.handleFilteringStatus) registerHTTP := d.HTTPRegister
httpRegister(http.MethodPost, "/control/filtering/config", f.handleFilteringConfig) if registerHTTP == nil {
httpRegister(http.MethodPost, "/control/filtering/add_url", f.handleFilteringAddURL) return
httpRegister(http.MethodPost, "/control/filtering/remove_url", f.handleFilteringRemoveURL) }
httpRegister(http.MethodPost, "/control/filtering/set_url", f.handleFilteringSetURL)
httpRegister(http.MethodPost, "/control/filtering/refresh", f.handleFilteringRefresh) registerHTTP(http.MethodPost, "/control/safebrowsing/enable", d.handleSafeBrowsingEnable)
httpRegister(http.MethodPost, "/control/filtering/set_rules", f.handleFilteringSetRules) registerHTTP(http.MethodPost, "/control/safebrowsing/disable", d.handleSafeBrowsingDisable)
httpRegister(http.MethodGet, "/control/filtering/check_host", f.handleCheckHost) registerHTTP(http.MethodGet, "/control/safebrowsing/status", d.handleSafeBrowsingStatus)
registerHTTP(http.MethodPost, "/control/parental/enable", d.handleParentalEnable)
registerHTTP(http.MethodPost, "/control/parental/disable", d.handleParentalDisable)
registerHTTP(http.MethodGet, "/control/parental/status", d.handleParentalStatus)
registerHTTP(http.MethodPost, "/control/safesearch/enable", d.handleSafeSearchEnable)
registerHTTP(http.MethodPost, "/control/safesearch/disable", d.handleSafeSearchDisable)
registerHTTP(http.MethodGet, "/control/safesearch/status", d.handleSafeSearchStatus)
registerHTTP(http.MethodGet, "/control/rewrite/list", d.handleRewriteList)
registerHTTP(http.MethodPost, "/control/rewrite/add", d.handleRewriteAdd)
registerHTTP(http.MethodPost, "/control/rewrite/delete", d.handleRewriteDelete)
registerHTTP(http.MethodGet, "/control/blocked_services/services", d.handleBlockedServicesAvailableServices)
registerHTTP(http.MethodGet, "/control/blocked_services/list", d.handleBlockedServicesList)
registerHTTP(http.MethodPost, "/control/blocked_services/set", d.handleBlockedServicesSet)
registerHTTP(http.MethodGet, "/control/filtering/status", d.handleFilteringStatus)
registerHTTP(http.MethodPost, "/control/filtering/config", d.handleFilteringConfig)
registerHTTP(http.MethodPost, "/control/filtering/add_url", d.handleFilteringAddURL)
registerHTTP(http.MethodPost, "/control/filtering/remove_url", d.handleFilteringRemoveURL)
registerHTTP(http.MethodPost, "/control/filtering/set_url", d.handleFilteringSetURL)
registerHTTP(http.MethodPost, "/control/filtering/refresh", d.handleFilteringRefresh)
registerHTTP(http.MethodPost, "/control/filtering/set_rules", d.handleFilteringSetRules)
registerHTTP(http.MethodGet, "/control/filtering/check_host", d.handleCheckHost)
} }
func checkFiltersUpdateIntervalHours(i uint32) bool { // ValidateUpdateIvl returns false if i is not a valid filters update interval.
func ValidateUpdateIvl(i uint32) bool {
return i == 0 || i == 1 || i == 12 || i == 1*24 || i == 3*24 || i == 7*24 return i == 0 || i == 1 || i == 12 || i == 1*24 || i == 3*24 || i == 7*24
} }

View File

@@ -133,34 +133,31 @@ func matchDomainWildcard(host, wildcard string) (ok bool) {
// 1. A and AAAA > CNAME // 1. A and AAAA > CNAME
// 2. wildcard > exact // 2. wildcard > exact
// 3. lower level wildcard > higher level wildcard // 3. lower level wildcard > higher level wildcard
//
// TODO(a.garipov): Replace with slices.Sort.
type rewritesSorted []*LegacyRewrite type rewritesSorted []*LegacyRewrite
// Len implements the sort.Interface interface for legacyRewritesSorted. // Len implements the sort.Interface interface for rewritesSorted.
func (a rewritesSorted) Len() (l int) { return len(a) } func (a rewritesSorted) Len() (l int) { return len(a) }
// Swap implements the sort.Interface interface for legacyRewritesSorted. // Swap implements the sort.Interface interface for rewritesSorted.
func (a rewritesSorted) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a rewritesSorted) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
// Less implements the sort.Interface interface for legacyRewritesSorted. // Less implements the sort.Interface interface for rewritesSorted.
func (a rewritesSorted) Less(i, j int) (less bool) { func (a rewritesSorted) Less(i, j int) (less bool) {
if a[i].Type == dns.TypeCNAME && a[j].Type != dns.TypeCNAME { ith, jth := a[i], a[j]
if ith.Type == dns.TypeCNAME && jth.Type != dns.TypeCNAME {
return true return true
} else if a[i].Type != dns.TypeCNAME && a[j].Type == dns.TypeCNAME { } else if ith.Type != dns.TypeCNAME && jth.Type == dns.TypeCNAME {
return false return false
} }
if isWildcard(a[i].Domain) { if iw, jw := isWildcard(ith.Domain), isWildcard(jth.Domain); iw != jw {
if !isWildcard(a[j].Domain) { return jw
return false
}
} else {
if isWildcard(a[j].Domain) {
return true
}
} }
// Both are wildcards. // Both are either wildcards or not.
return len(a[i].Domain) > len(a[j].Domain) return len(ith.Domain) > len(jth.Domain)
} }
// prepareRewrites normalizes and validates all legacy DNS rewrites. // prepareRewrites normalizes and validates all legacy DNS rewrites.
@@ -313,9 +310,3 @@ func (d *DNSFilter) handleRewriteDelete(w http.ResponseWriter, r *http.Request)
d.Config.ConfigModified() d.Config.ConfigModified()
} }
func (d *DNSFilter) registerRewritesHandlers() {
d.Config.HTTPRegister(http.MethodGet, "/control/rewrite/list", d.handleRewriteList)
d.Config.HTTPRegister(http.MethodPost, "/control/rewrite/add", d.handleRewriteAdd)
d.Config.HTTPRegister(http.MethodPost, "/control/rewrite/delete", d.handleRewriteDelete)
}

View File

@@ -12,7 +12,7 @@ import (
// TODO(e.burkov): All the tests in this file may and should me merged together. // TODO(e.burkov): All the tests in this file may and should me merged together.
func TestRewrites(t *testing.T) { func TestRewrites(t *testing.T) {
d := newForTest(t, nil, nil) d, _ := newForTest(t, nil, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
d.Rewrites = []*LegacyRewrite{{ d.Rewrites = []*LegacyRewrite{{
@@ -188,7 +188,7 @@ func TestRewrites(t *testing.T) {
} }
func TestRewritesLevels(t *testing.T) { func TestRewritesLevels(t *testing.T) {
d := newForTest(t, nil, nil) d, _ := newForTest(t, nil, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
// Exact host, wildcard L2, wildcard L3. // Exact host, wildcard L2, wildcard L3.
d.Rewrites = []*LegacyRewrite{{ d.Rewrites = []*LegacyRewrite{{
@@ -235,7 +235,7 @@ func TestRewritesLevels(t *testing.T) {
} }
func TestRewritesExceptionCNAME(t *testing.T) { func TestRewritesExceptionCNAME(t *testing.T) {
d := newForTest(t, nil, nil) d, _ := newForTest(t, nil, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
// Wildcard and exception for a sub-domain. // Wildcard and exception for a sub-domain.
d.Rewrites = []*LegacyRewrite{{ d.Rewrites = []*LegacyRewrite{{
@@ -286,7 +286,7 @@ func TestRewritesExceptionCNAME(t *testing.T) {
} }
func TestRewritesExceptionIP(t *testing.T) { func TestRewritesExceptionIP(t *testing.T) {
d := newForTest(t, nil, nil) d, _ := newForTest(t, nil, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
// Exception for AAAA record. // Exception for AAAA record.
d.Rewrites = []*LegacyRewrite{{ d.Rewrites = []*LegacyRewrite{{

View File

@@ -415,17 +415,3 @@ func (d *DNSFilter) handleParentalStatus(w http.ResponseWriter, r *http.Request)
aghhttp.Error(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err) aghhttp.Error(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
} }
} }
func (d *DNSFilter) registerSecurityHandlers() {
d.Config.HTTPRegister(http.MethodPost, "/control/safebrowsing/enable", d.handleSafeBrowsingEnable)
d.Config.HTTPRegister(http.MethodPost, "/control/safebrowsing/disable", d.handleSafeBrowsingDisable)
d.Config.HTTPRegister(http.MethodGet, "/control/safebrowsing/status", d.handleSafeBrowsingStatus)
d.Config.HTTPRegister(http.MethodPost, "/control/parental/enable", d.handleParentalEnable)
d.Config.HTTPRegister(http.MethodPost, "/control/parental/disable", d.handleParentalDisable)
d.Config.HTTPRegister(http.MethodGet, "/control/parental/status", d.handleParentalStatus)
d.Config.HTTPRegister(http.MethodPost, "/control/safesearch/enable", d.handleSafeSearchEnable)
d.Config.HTTPRegister(http.MethodPost, "/control/safesearch/disable", d.handleSafeSearchDisable)
d.Config.HTTPRegister(http.MethodGet, "/control/safesearch/status", d.handleSafeSearchStatus)
}

View File

@@ -107,7 +107,7 @@ func TestSafeBrowsingCache(t *testing.T) {
} }
func TestSBPC_checkErrorUpstream(t *testing.T) { func TestSBPC_checkErrorUpstream(t *testing.T) {
d := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil) d, _ := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
ups := aghtest.NewErrorUpstream() ups := aghtest.NewErrorUpstream()
@@ -128,7 +128,7 @@ func TestSBPC_checkErrorUpstream(t *testing.T) {
} }
func TestSBPC(t *testing.T) { func TestSBPC(t *testing.T) {
d := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil) d, _ := newForTest(t, &Config{SafeBrowsingEnabled: true}, nil)
t.Cleanup(d.Close) t.Cleanup(d.Close)
const hostname = "example.org" const hostname = "example.org"

View File

@@ -8,12 +8,14 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"path"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
"github.com/AdguardTeam/golibs/netutil" "github.com/AdguardTeam/golibs/netutil"
"github.com/AdguardTeam/golibs/timeutil" "github.com/AdguardTeam/golibs/timeutil"
@@ -32,7 +34,8 @@ const sessionTokenSize = 16
type session struct { type session struct {
userName string userName string
expire uint32 // expiration time (in seconds) // expire is the expiration time, in seconds.
expire uint32
} }
func (s *session) serialize() []byte { func (s *session) serialize() []byte {
@@ -64,29 +67,29 @@ func (s *session) deserialize(data []byte) bool {
// Auth - global object // Auth - global object
type Auth struct { type Auth struct {
db *bbolt.DB db *bbolt.DB
blocker *authRateLimiter raleLimiter *authRateLimiter
sessions map[string]*session sessions map[string]*session
users []User users []webUser
lock sync.Mutex lock sync.Mutex
sessionTTL uint32 sessionTTL uint32
} }
// User object // webUser represents a user of the Web UI.
type User struct { type webUser struct {
Name string `yaml:"name"` Name string `yaml:"name"`
PasswordHash string `yaml:"password"` // bcrypt hash PasswordHash string `yaml:"password"`
} }
// InitAuth - create a global object // InitAuth - create a global object
func InitAuth(dbFilename string, users []User, sessionTTL uint32, blocker *authRateLimiter) *Auth { func InitAuth(dbFilename string, users []webUser, sessionTTL uint32, rateLimiter *authRateLimiter) *Auth {
log.Info("Initializing auth module: %s", dbFilename) log.Info("Initializing auth module: %s", dbFilename)
a := &Auth{ a := &Auth{
sessionTTL: sessionTTL, sessionTTL: sessionTTL,
blocker: blocker, raleLimiter: rateLimiter,
sessions: make(map[string]*session), sessions: make(map[string]*session),
users: users, users: users,
} }
var err error var err error
a.db, err = bbolt.Open(dbFilename, 0o644, nil) a.db, err = bbolt.Open(dbFilename, 0o644, nil)
@@ -326,35 +329,25 @@ func newSessionToken() (data []byte, err error) {
return randData, nil return randData, nil
} }
// cookieTimeFormat is the format to be used in (time.Time).Format for cookie's // newCookie creates a new authentication cookie.
// expiry field. func (a *Auth) newCookie(req loginJSON, addr string) (c *http.Cookie, err error) {
const cookieTimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT" rateLimiter := a.raleLimiter
u, ok := a.findUser(req.Name, req.Password)
// cookieExpiryFormat returns the formatted exp to be used in cookie string. if !ok {
// It's quite simple for now, but probably will be expanded in the future. if rateLimiter != nil {
func cookieExpiryFormat(exp time.Time) (formatted string) { rateLimiter.inc(addr)
return exp.Format(cookieTimeFormat)
}
func (a *Auth) httpCookie(req loginJSON, addr string) (cookie string, err error) {
blocker := a.blocker
u := a.UserFind(req.Name, req.Password)
if len(u.Name) == 0 {
if blocker != nil {
blocker.inc(addr)
} }
return "", err return nil, errors.Error("invalid username or password")
} }
if blocker != nil { if rateLimiter != nil {
blocker.remove(addr) rateLimiter.remove(addr)
} }
var sess []byte sess, err := newSessionToken()
sess, err = newSessionToken()
if err != nil { if err != nil {
return "", err return nil, fmt.Errorf("generating token: %w", err)
} }
now := time.Now().UTC() now := time.Now().UTC()
@@ -364,11 +357,15 @@ func (a *Auth) httpCookie(req loginJSON, addr string) (cookie string, err error)
expire: uint32(now.Unix()) + a.sessionTTL, expire: uint32(now.Unix()) + a.sessionTTL,
}) })
return fmt.Sprintf( return &http.Cookie{
"%s=%s; Path=/; HttpOnly; Expires=%s", Name: sessionCookieName,
sessionCookieName, hex.EncodeToString(sess), Value: hex.EncodeToString(sess),
cookieExpiryFormat(now.Add(cookieTTL)), Path: "/",
), nil Expires: now.Add(cookieTTL),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}, nil
} }
// realIP extracts the real IP address of the client from an HTTP request using // realIP extracts the real IP address of the client from an HTTP request using
@@ -436,8 +433,8 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
if blocker := Context.auth.blocker; blocker != nil { if rateLimiter := Context.auth.raleLimiter; rateLimiter != nil {
if left := blocker.check(remoteAddr); left > 0 { if left := rateLimiter.check(remoteAddr); left > 0 {
w.Header().Set("Retry-After", strconv.Itoa(int(left.Seconds()))) w.Header().Set("Retry-After", strconv.Itoa(int(left.Seconds())))
aghhttp.Error(r, w, http.StatusTooManyRequests, "auth: blocked for %s", left) aghhttp.Error(r, w, http.StatusTooManyRequests, "auth: blocked for %s", left)
@@ -445,10 +442,9 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
} }
} }
var cookie string cookie, err := Context.auth.newCookie(req, remoteAddr)
cookie, err = Context.auth.httpCookie(req, remoteAddr)
if err != nil { if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "crypto rand reader: %s", err) aghhttp.Error(r, w, http.StatusForbidden, "%s", err)
return return
} }
@@ -462,20 +458,11 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
log.Error("auth: unknown ip") log.Error("auth: unknown ip")
} }
if len(cookie) == 0 {
log.Info("auth: failed to login user %q from ip %v", req.Name, ip)
time.Sleep(1 * time.Second)
http.Error(w, "invalid username or password", http.StatusBadRequest)
return
}
log.Info("auth: user %q successfully logged in from ip %v", req.Name, ip) log.Info("auth: user %q successfully logged in from ip %v", req.Name, ip)
http.SetCookie(w, cookie)
h := w.Header() h := w.Header()
h.Set("Set-Cookie", cookie)
h.Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") h.Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
h.Set("Pragma", "no-cache") h.Set("Pragma", "no-cache")
h.Set("Expires", "0") h.Set("Expires", "0")
@@ -484,17 +471,31 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
} }
func handleLogout(w http.ResponseWriter, r *http.Request) { func handleLogout(w http.ResponseWriter, r *http.Request) {
cookie := r.Header.Get("Cookie") respHdr := w.Header()
sess := parseCookie(cookie) c, err := r.Cookie(sessionCookieName)
if err != nil {
// The only error that is returned from r.Cookie is [http.ErrNoCookie].
// The user is already logged out.
respHdr.Set("Location", "/login.html")
w.WriteHeader(http.StatusFound)
Context.auth.RemoveSession(sess) return
}
w.Header().Set("Location", "/login.html") Context.auth.RemoveSession(c.Value)
s := fmt.Sprintf("%s=; Path=/; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT", c = &http.Cookie{
sessionCookieName) Name: sessionCookieName,
w.Header().Set("Set-Cookie", s) Value: "",
Path: "/",
Expires: time.Unix(0, 0),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
respHdr.Set("Location", "/login.html")
respHdr.Set("Set-Cookie", c.String())
w.WriteHeader(http.StatusFound) w.WriteHeader(http.StatusFound)
} }
@@ -504,101 +505,108 @@ func RegisterAuthHandlers() {
httpRegister(http.MethodGet, "/control/logout", handleLogout) httpRegister(http.MethodGet, "/control/logout", handleLogout)
} }
func parseCookie(cookie string) string {
pairs := strings.Split(cookie, ";")
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
kv := strings.SplitN(pair, "=", 2)
if len(kv) != 2 {
continue
}
if kv[0] == sessionCookieName {
return kv[1]
}
}
return ""
}
// optionalAuthThird return true if user should authenticate first. // optionalAuthThird return true if user should authenticate first.
func optionalAuthThird(w http.ResponseWriter, r *http.Request) (authFirst bool) { func optionalAuthThird(w http.ResponseWriter, r *http.Request) (mustAuth bool) {
authFirst = false if glProcessCookie(r) {
log.Debug("auth: authentication is handled by GL-Inet submodule")
return false
}
// redirect to login page if not authenticated // redirect to login page if not authenticated
ok := false isAuthenticated := false
cookie, err := r.Cookie(sessionCookieName) cookie, err := r.Cookie(sessionCookieName)
if err != nil {
if glProcessCookie(r) { // The only error that is returned from r.Cookie is [http.ErrNoCookie].
log.Debug("auth: authentication was handled by GL-Inet submodule") // Check Basic authentication.
ok = true user, pass, hasBasic := r.BasicAuth()
} else if err == nil { if hasBasic {
r := Context.auth.checkSession(cookie.Value) _, isAuthenticated = Context.auth.findUser(user, pass)
if r == checkSessionOK { if !isAuthenticated {
ok = true
} else if r < 0 {
log.Debug("auth: invalid cookie value: %s", cookie)
}
} else {
// there's no Cookie, check Basic authentication
user, pass, ok2 := r.BasicAuth()
if ok2 {
u := Context.auth.UserFind(user, pass)
if len(u.Name) != 0 {
ok = true
} else {
log.Info("auth: invalid Basic Authorization value") log.Info("auth: invalid Basic Authorization value")
} }
} }
} } else {
if !ok { res := Context.auth.checkSession(cookie.Value)
if r.URL.Path == "/" || r.URL.Path == "/index.html" { isAuthenticated = res == checkSessionOK
if glProcessRedirect(w, r) { if !isAuthenticated {
log.Debug("auth: redirected to login page by GL-Inet submodule") log.Debug("auth: invalid cookie value: %s", cookie)
} else {
w.Header().Set("Location", "/login.html")
w.WriteHeader(http.StatusFound)
}
} else {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("Forbidden"))
} }
authFirst = true
} }
return authFirst if isAuthenticated {
return false
}
if p := r.URL.Path; p == "/" || p == "/index.html" {
if glProcessRedirect(w, r) {
log.Debug("auth: redirected to login page by GL-Inet submodule")
} else {
log.Debug("auth: redirected to login page")
w.Header().Set("Location", "/login.html")
w.WriteHeader(http.StatusFound)
}
} else {
log.Debug("auth: responded with forbidden to %s %s", r.Method, p)
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("Forbidden"))
}
return true
} }
func optionalAuth(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { // TODO(a.garipov): Use [http.Handler] consistently everywhere throughout the
// project.
func optionalAuth(
h func(http.ResponseWriter, *http.Request),
) (wrapped func(http.ResponseWriter, *http.Request)) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/login.html" { p := r.URL.Path
// redirect to dashboard if already authenticated authRequired := Context.auth != nil && Context.auth.AuthRequired()
authRequired := Context.auth != nil && Context.auth.AuthRequired() if p == "/login.html" {
cookie, err := r.Cookie(sessionCookieName) cookie, err := r.Cookie(sessionCookieName)
if authRequired && err == nil { if authRequired && err == nil {
r := Context.auth.checkSession(cookie.Value) // Redirect to the dashboard if already authenticated.
if r == checkSessionOK { res := Context.auth.checkSession(cookie.Value)
if res == checkSessionOK {
w.Header().Set("Location", "/") w.Header().Set("Location", "/")
w.WriteHeader(http.StatusFound) w.WriteHeader(http.StatusFound)
return return
} else if r == checkSessionNotFound {
log.Debug("auth: invalid cookie value: %s", cookie)
} }
}
} else if strings.HasPrefix(r.URL.Path, "/assets/") || log.Debug("auth: invalid cookie value: %s", cookie)
strings.HasPrefix(r.URL.Path, "/login.") { }
// process as usual } else if isPublicResource(p) {
// no additional auth requirements // Process as usual, no additional auth requirements.
} else if Context.auth != nil && Context.auth.AuthRequired() { } else if authRequired {
if optionalAuthThird(w, r) { if optionalAuthThird(w, r) {
return return
} }
} }
handler(w, r) h(w, r)
} }
} }
// isPublicResource returns true if p is a path to a public resource.
func isPublicResource(p string) (ok bool) {
isAsset, err := path.Match("/assets/*", p)
if err != nil {
// The only error that is returned from path.Match is
// [path.ErrBadPattern]. This is a programmer error.
panic(fmt.Errorf("bad asset pattern: %w", err))
}
isLogin, err := path.Match("/login.*", p)
if err != nil {
// Same as above.
panic(fmt.Errorf("bad login pattern: %w", err))
}
return isAsset || isLogin
}
type authHandler struct { type authHandler struct {
handler http.Handler handler http.Handler
} }
@@ -612,7 +620,7 @@ func optionalAuthHandler(handler http.Handler) http.Handler {
} }
// UserAdd - add new user // UserAdd - add new user
func (a *Auth) UserAdd(u *User, password string) { func (a *Auth) UserAdd(u *webUser, password string) {
if len(password) == 0 { if len(password) == 0 {
return return
} }
@@ -631,31 +639,35 @@ func (a *Auth) UserAdd(u *User, password string) {
log.Debug("auth: added user: %s", u.Name) log.Debug("auth: added user: %s", u.Name)
} }
// UserFind - find a user // findUser returns a user if there is one.
func (a *Auth) UserFind(login, password string) User { func (a *Auth) findUser(login, password string) (u webUser, ok bool) {
a.lock.Lock() a.lock.Lock()
defer a.lock.Unlock() defer a.lock.Unlock()
for _, u := range a.users {
for _, u = range a.users {
if u.Name == login && if u.Name == login &&
bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil { bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
return u return u, true
} }
} }
return User{}
return webUser{}, false
} }
// getCurrentUser returns the current user. It returns an empty User if the // getCurrentUser returns the current user. It returns an empty User if the
// user is not found. // user is not found.
func (a *Auth) getCurrentUser(r *http.Request) User { func (a *Auth) getCurrentUser(r *http.Request) (u webUser) {
cookie, err := r.Cookie(sessionCookieName) cookie, err := r.Cookie(sessionCookieName)
if err != nil { if err != nil {
// There's no Cookie, check Basic authentication. // There's no Cookie, check Basic authentication.
user, pass, ok := r.BasicAuth() user, pass, ok := r.BasicAuth()
if ok { if ok {
return Context.auth.UserFind(user, pass) u, _ = Context.auth.findUser(user, pass)
return u
} }
return User{} return webUser{}
} }
a.lock.Lock() a.lock.Lock()
@@ -663,20 +675,20 @@ func (a *Auth) getCurrentUser(r *http.Request) User {
s, ok := a.sessions[cookie.Value] s, ok := a.sessions[cookie.Value]
if !ok { if !ok {
return User{} return webUser{}
} }
for _, u := range a.users { for _, u = range a.users {
if u.Name == s.userName { if u.Name == s.userName {
return u return u
} }
} }
return User{} return webUser{}
} }
// GetUsers - get users // GetUsers - get users
func (a *Auth) GetUsers() []User { func (a *Auth) GetUsers() []webUser {
a.lock.Lock() a.lock.Lock()
users := a.users users := a.users
a.lock.Unlock() a.lock.Unlock()

View File

@@ -43,14 +43,14 @@ func TestAuth(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
fn := filepath.Join(dir, "sessions.db") fn := filepath.Join(dir, "sessions.db")
users := []User{{ users := []webUser{{
Name: "name", Name: "name",
PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2", PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2",
}} }}
a := InitAuth(fn, nil, 60, nil) a := InitAuth(fn, nil, 60, nil)
s := session{} s := session{}
user := User{Name: "name"} user := webUser{Name: "name"}
a.UserAdd(&user, "password") a.UserAdd(&user, "password")
assert.Equal(t, checkSessionNotFound, a.checkSession("notfound")) assert.Equal(t, checkSessionNotFound, a.checkSession("notfound"))
@@ -84,7 +84,8 @@ func TestAuth(t *testing.T) {
a.storeSession(sess, &s) a.storeSession(sess, &s)
a.Close() a.Close()
u := a.UserFind("name", "password") u, ok := a.findUser("name", "password")
assert.True(t, ok)
assert.NotEmpty(t, u.Name) assert.NotEmpty(t, u.Name)
time.Sleep(3 * time.Second) time.Sleep(3 * time.Second)
@@ -118,7 +119,7 @@ func TestAuthHTTP(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
fn := filepath.Join(dir, "sessions.db") fn := filepath.Join(dir, "sessions.db")
users := []User{ users := []webUser{
{Name: "name", PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2"}, {Name: "name", PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2"},
} }
Context.auth = InitAuth(fn, users, 60, nil) Context.auth = InitAuth(fn, users, 60, nil)
@@ -150,18 +151,19 @@ func TestAuthHTTP(t *testing.T) {
assert.True(t, handlerCalled) assert.True(t, handlerCalled)
// perform login // perform login
cookie, err := Context.auth.httpCookie(loginJSON{Name: "name", Password: "password"}, "") cookie, err := Context.auth.newCookie(loginJSON{Name: "name", Password: "password"}, "")
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, cookie) require.NotNil(t, cookie)
// get / // get /
handler2 = optionalAuth(handler) handler2 = optionalAuth(handler)
w.hdr = make(http.Header) w.hdr = make(http.Header)
r.Header.Set("Cookie", cookie) r.Header.Set("Cookie", cookie.String())
r.URL = &url.URL{Path: "/"} r.URL = &url.URL{Path: "/"}
handlerCalled = false handlerCalled = false
handler2(&w, &r) handler2(&w, &r)
assert.True(t, handlerCalled) assert.True(t, handlerCalled)
r.Header.Del("Cookie") r.Header.Del("Cookie")
// get / with basic auth // get / with basic auth
@@ -177,7 +179,7 @@ func TestAuthHTTP(t *testing.T) {
// get login page with a valid cookie - we're redirected to / // get login page with a valid cookie - we're redirected to /
handler2 = optionalAuth(handler) handler2 = optionalAuth(handler)
w.hdr = make(http.Header) w.hdr = make(http.Header)
r.Header.Set("Cookie", cookie) r.Header.Set("Cookie", cookie.String())
r.URL = &url.URL{Path: loginURL} r.URL = &url.URL{Path: loginURL}
handlerCalled = false handlerCalled = false
handler2(&w, &r) handler2(&w, &r)

View File

@@ -93,13 +93,7 @@ func (clients *clientsContainer) handleGetClients(w http.ResponseWriter, r *http
data.Tags = clientTags data.Tags = clientTags
w.Header().Set("Content-Type", "application/json") _ = aghhttp.WriteJSONResponse(w, r, data)
e := json.NewEncoder(w).Encode(data)
if e != nil {
aghhttp.Error(r, w, http.StatusInternalServerError, "failed to encode to json: %v", e)
return
}
} }
// Convert JSON object to Client object // Convert JSON object to Client object
@@ -249,11 +243,7 @@ func (clients *clientsContainer) handleFindClient(w http.ResponseWriter, r *http
}) })
} }
w.Header().Set("Content-Type", "application/json") _ = aghhttp.WriteJSONResponse(w, r, data)
err := json.NewEncoder(w).Encode(data)
if err != nil {
aghhttp.Error(r, w, http.StatusInternalServerError, "Couldn't write response: %s", err)
}
} }
// findRuntime looks up the IP in runtime and temporary storages, like // findRuntime looks up the IP in runtime and temporary storages, like

View File

@@ -14,7 +14,6 @@ import (
"github.com/AdguardTeam/AdGuardHome/internal/filtering" "github.com/AdguardTeam/AdGuardHome/internal/filtering"
"github.com/AdguardTeam/AdGuardHome/internal/querylog" "github.com/AdguardTeam/AdGuardHome/internal/querylog"
"github.com/AdguardTeam/AdGuardHome/internal/stats" "github.com/AdguardTeam/AdGuardHome/internal/stats"
"github.com/AdguardTeam/AdGuardHome/internal/version"
"github.com/AdguardTeam/dnsproxy/fastip" "github.com/AdguardTeam/dnsproxy/fastip"
"github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
@@ -23,10 +22,9 @@ import (
yaml "gopkg.in/yaml.v3" yaml "gopkg.in/yaml.v3"
) )
const ( // dataDir is the name of a directory under the working one to store some
dataDir = "data" // data storage // persistent data.
filterDir = "filters" // cache location for downloaded filters, it's under DataDir const dataDir = "data"
)
// logSettings are the logging settings part of the configuration file. // logSettings are the logging settings part of the configuration file.
// //
@@ -87,10 +85,10 @@ type configuration struct {
// It's reset after config is parsed // It's reset after config is parsed
fileData []byte fileData []byte
BindHost net.IP `yaml:"bind_host"` // BindHost is the IP address of the HTTP server to bind to BindHost net.IP `yaml:"bind_host"` // BindHost is the IP address of the HTTP server to bind to
BindPort int `yaml:"bind_port"` // BindPort is the port the HTTP server BindPort int `yaml:"bind_port"` // BindPort is the port the HTTP server
BetaBindPort int `yaml:"beta_bind_port"` // BetaBindPort is the port for new client BetaBindPort int `yaml:"beta_bind_port"` // BetaBindPort is the port for new client
Users []User `yaml:"users"` // Users that can access HTTP server Users []webUser `yaml:"users"` // Users that can access HTTP server
// AuthAttempts is the maximum number of failed login attempts a user // AuthAttempts is the maximum number of failed login attempts a user
// can do before being blocked. // can do before being blocked.
AuthAttempts uint `yaml:"auth_attempts"` AuthAttempts uint `yaml:"auth_attempts"`
@@ -108,9 +106,16 @@ type configuration struct {
DNS dnsConfig `yaml:"dns"` DNS dnsConfig `yaml:"dns"`
TLS tlsConfigSettings `yaml:"tls"` TLS tlsConfigSettings `yaml:"tls"`
Filters []filter `yaml:"filters"` // Filters reflects the filters from [filtering.Config]. It's cloned to the
WhitelistFilters []filter `yaml:"whitelist_filters"` // config used in the filtering module at the startup. Afterwards it's
UserRules []string `yaml:"user_rules"` // cloned from the filtering module back here.
//
// TODO(e.burkov): Move all the filtering configuration fields into the
// only configuration subsection covering the changes with a single
// migration.
Filters []filtering.FilterYAML `yaml:"filters"`
WhitelistFilters []filtering.FilterYAML `yaml:"whitelist_filters"`
UserRules []string `yaml:"user_rules"`
DHCP *dhcpd.ServerConfig `yaml:"dhcp"` DHCP *dhcpd.ServerConfig `yaml:"dhcp"`
@@ -145,9 +150,7 @@ type dnsConfig struct {
dnsforward.FilteringConfig `yaml:",inline"` dnsforward.FilteringConfig `yaml:",inline"`
FilteringEnabled bool `yaml:"filtering_enabled"` // whether or not use filter lists DnsfilterConf *filtering.Config `yaml:",inline"`
FiltersUpdateIntervalHours uint32 `yaml:"filters_update_interval"` // time period to update filters (in hours)
DnsfilterConf filtering.Config `yaml:",inline"`
// UpstreamTimeout is the timeout for querying upstream servers. // UpstreamTimeout is the timeout for querying upstream servers.
UpstreamTimeout timeutil.Duration `yaml:"upstream_timeout"` UpstreamTimeout timeutil.Duration `yaml:"upstream_timeout"`
@@ -193,15 +196,20 @@ type tlsConfigSettings struct {
// //
// TODO(a.garipov, e.burkov): This global is awful and must be removed. // TODO(a.garipov, e.burkov): This global is awful and must be removed.
var config = &configuration{ var config = &configuration{
BindPort: 3000, BindPort: 3000,
BetaBindPort: 0, BetaBindPort: 0,
BindHost: net.IP{0, 0, 0, 0}, BindHost: net.IP{0, 0, 0, 0},
AuthAttempts: 5, AuthAttempts: 5,
AuthBlockMin: 15, AuthBlockMin: 15,
WebSessionTTLHours: 30 * 24,
DNS: dnsConfig{ DNS: dnsConfig{
BindHosts: []net.IP{{0, 0, 0, 0}}, BindHosts: []net.IP{{0, 0, 0, 0}},
Port: defaultPortDNS, Port: defaultPortDNS,
StatsInterval: 1, StatsInterval: 1,
QueryLogEnabled: true,
QueryLogFileEnabled: true,
QueryLogInterval: timeutil.Duration{Duration: 90 * timeutil.Day},
QueryLogMemSize: 1000,
FilteringConfig: dnsforward.FilteringConfig{ FilteringConfig: dnsforward.FilteringConfig{
ProtectionEnabled: true, // whether or not use any of filtering features ProtectionEnabled: true, // whether or not use any of filtering features
BlockingMode: dnsforward.BlockingModeDefault, BlockingMode: dnsforward.BlockingModeDefault,
@@ -222,18 +230,42 @@ var config = &configuration{
// was later increased to 300 due to https://github.com/AdguardTeam/AdGuardHome/issues/2257 // was later increased to 300 due to https://github.com/AdguardTeam/AdGuardHome/issues/2257
MaxGoroutines: 300, MaxGoroutines: 300,
}, },
FilteringEnabled: true, // whether or not use filter lists DnsfilterConf: &filtering.Config{
FiltersUpdateIntervalHours: 24, SafeBrowsingCacheSize: 1 * 1024 * 1024,
UpstreamTimeout: timeutil.Duration{Duration: dnsforward.DefaultTimeout}, SafeSearchCacheSize: 1 * 1024 * 1024,
UsePrivateRDNS: true, ParentalCacheSize: 1 * 1024 * 1024,
CacheTime: 30,
FilteringEnabled: true,
FiltersUpdateIntervalHours: 24,
},
UpstreamTimeout: timeutil.Duration{Duration: dnsforward.DefaultTimeout},
UsePrivateRDNS: true,
}, },
TLS: tlsConfigSettings{ TLS: tlsConfigSettings{
PortHTTPS: defaultPortHTTPS, PortHTTPS: defaultPortHTTPS,
PortDNSOverTLS: defaultPortTLS, // needs to be passed through to dnsproxy PortDNSOverTLS: defaultPortTLS, // needs to be passed through to dnsproxy
PortDNSOverQUIC: defaultPortQUIC, PortDNSOverQUIC: defaultPortQUIC,
}, },
Filters: []filtering.FilterYAML{{
Filter: filtering.Filter{ID: 1},
Enabled: true,
URL: "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt",
Name: "AdGuard DNS filter",
}, {
Filter: filtering.Filter{ID: 2},
Enabled: false,
URL: "https://adaway.org/hosts.txt",
Name: "AdAway Default Blocklist",
}},
DHCP: &dhcpd.ServerConfig{ DHCP: &dhcpd.ServerConfig{
LocalDomainName: "lan", LocalDomainName: "lan",
Conf4: dhcpd.V4ServerConf{
LeaseDuration: dhcpd.DefaultDHCPLeaseTTL,
ICMPTimeout: dhcpd.DefaultDHCPTimeoutICMP,
},
Conf6: dhcpd.V6ServerConf{
LeaseDuration: dhcpd.DefaultDHCPLeaseTTL,
},
}, },
Clients: &clientsConfig{ Clients: &clientsConfig{
Sources: &clientSourcesConf{ Sources: &clientSourcesConf{
@@ -255,31 +287,6 @@ var config = &configuration{
SchemaVersion: currentSchemaVersion, SchemaVersion: currentSchemaVersion,
} }
// initConfig initializes default configuration for the current OS&ARCH
func initConfig() {
config.WebSessionTTLHours = 30 * 24
config.DNS.QueryLogEnabled = true
config.DNS.QueryLogFileEnabled = true
config.DNS.QueryLogInterval = timeutil.Duration{Duration: 90 * timeutil.Day}
config.DNS.QueryLogMemSize = 1000
config.DNS.CacheSize = 4 * 1024 * 1024
config.DNS.DnsfilterConf.SafeBrowsingCacheSize = 1 * 1024 * 1024
config.DNS.DnsfilterConf.SafeSearchCacheSize = 1 * 1024 * 1024
config.DNS.DnsfilterConf.ParentalCacheSize = 1 * 1024 * 1024
config.DNS.DnsfilterConf.CacheTime = 30
config.Filters = defaultFilters()
config.DHCP.Conf4.LeaseDuration = dhcpd.DefaultDHCPLeaseTTL
config.DHCP.Conf4.ICMPTimeout = dhcpd.DefaultDHCPTimeoutICMP
config.DHCP.Conf6.LeaseDuration = dhcpd.DefaultDHCPLeaseTTL
if ch := version.Channel(); ch == version.ChannelEdge || ch == version.ChannelDevelopment {
config.BetaBindPort = 3001
}
}
// getConfigFilename returns path to the current config file // getConfigFilename returns path to the current config file
func (c *configuration) getConfigFilename() string { func (c *configuration) getConfigFilename() string {
configFile, err := filepath.EvalSymlinks(Context.configFilename) configFile, err := filepath.EvalSymlinks(Context.configFilename)
@@ -348,8 +355,8 @@ func parseConfig() (err error) {
return fmt.Errorf("validating udp ports: %w", err) return fmt.Errorf("validating udp ports: %w", err)
} }
if !checkFiltersUpdateIntervalHours(config.DNS.FiltersUpdateIntervalHours) { if !filtering.ValidateUpdateIvl(config.DNS.DnsfilterConf.FiltersUpdateIntervalHours) {
config.DNS.FiltersUpdateIntervalHours = 24 config.DNS.DnsfilterConf.FiltersUpdateIntervalHours = 24
} }
if config.DNS.UpstreamTimeout.Duration == 0 { if config.DNS.UpstreamTimeout.Duration == 0 {
@@ -418,10 +425,11 @@ func (c *configuration) write() (err error) {
config.DNS.AnonymizeClientIP = dc.AnonymizeClientIP config.DNS.AnonymizeClientIP = dc.AnonymizeClientIP
} }
if Context.dnsFilter != nil { if Context.filters != nil {
c := filtering.Config{} Context.filters.WriteDiskConfig(config.DNS.DnsfilterConf)
Context.dnsFilter.WriteDiskConfig(&c) config.Filters = config.DNS.DnsfilterConf.Filters
config.DNS.DnsfilterConf = c config.WhitelistFilters = config.DNS.DnsfilterConf.WhitelistFilters
config.UserRules = config.DNS.DnsfilterConf.UserRules
} }
if s := Context.dnsServer; s != nil { if s := Context.dnsServer; s != nil {

View File

@@ -8,6 +8,7 @@ import (
"net/url" "net/url"
"runtime" "runtime"
"strings" "strings"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/aghnet" "github.com/AdguardTeam/AdGuardHome/internal/aghnet"
@@ -97,16 +98,16 @@ func collectDNSAddresses() (addrs []string, err error) {
// statusResponse is a response for /control/status endpoint. // statusResponse is a response for /control/status endpoint.
type statusResponse struct { type statusResponse struct {
Version string `json:"version"`
Language string `json:"language"`
DNSAddrs []string `json:"dns_addresses"` DNSAddrs []string `json:"dns_addresses"`
DNSPort int `json:"dns_port"` DNSPort int `json:"dns_port"`
HTTPPort int `json:"http_port"` HTTPPort int `json:"http_port"`
IsProtectionEnabled bool `json:"protection_enabled"` IsProtectionEnabled bool `json:"protection_enabled"`
// TODO(e.burkov): Inspect if front-end doesn't requires this field as // TODO(e.burkov): Inspect if front-end doesn't requires this field as
// openapi.yaml declares. // openapi.yaml declares.
IsDHCPAvailable bool `json:"dhcp_available"` IsDHCPAvailable bool `json:"dhcp_available"`
IsRunning bool `json:"running"` IsRunning bool `json:"running"`
Version string `json:"version"`
Language string `json:"language"`
} }
func handleStatus(w http.ResponseWriter, r *http.Request) { func handleStatus(w http.ResponseWriter, r *http.Request) {
@@ -125,12 +126,12 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
defer config.RUnlock() defer config.RUnlock()
resp = statusResponse{ resp = statusResponse{
Version: version.Version(),
DNSAddrs: dnsAddrs, DNSAddrs: dnsAddrs,
DNSPort: config.DNS.Port, DNSPort: config.DNS.Port,
HTTPPort: config.BindPort, HTTPPort: config.BindPort,
IsRunning: isRunning(),
Version: version.Version(),
Language: config.Language, Language: config.Language,
IsRunning: isRunning(),
} }
}() }()
@@ -146,13 +147,7 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
resp.IsDHCPAvailable = Context.dhcpServer != nil resp.IsDHCPAvailable = Context.dhcpServer != nil
} }
w.Header().Set("Content-Type", "application/json") _ = aghhttp.WriteJSONResponse(w, r, resp)
err = json.NewEncoder(w).Encode(resp)
if err != nil {
aghhttp.Error(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
return
}
} }
type profileJSON struct { type profileJSON struct {
@@ -162,13 +157,16 @@ type profileJSON struct {
func handleGetProfile(w http.ResponseWriter, r *http.Request) { func handleGetProfile(w http.ResponseWriter, r *http.Request) {
pj := profileJSON{} pj := profileJSON{}
u := Context.auth.getCurrentUser(r) u := Context.auth.getCurrentUser(r)
pj.Name = u.Name pj.Name = u.Name
data, err := json.Marshal(pj) data, err := json.Marshal(pj)
if err != nil { if err != nil {
aghhttp.Error(r, w, http.StatusInternalServerError, "json.Marshal: %s", err) aghhttp.Error(r, w, http.StatusInternalServerError, "json.Marshal: %s", err)
return return
} }
_, _ = w.Write(data) _, _ = w.Write(data)
} }
@@ -199,19 +197,29 @@ func httpRegister(method, url string, handler http.HandlerFunc) {
Context.mux.Handle(url, postInstallHandler(optionalAuthHandler(gziphandler.GzipHandler(ensureHandler(method, handler))))) Context.mux.Handle(url, postInstallHandler(optionalAuthHandler(gziphandler.GzipHandler(ensureHandler(method, handler)))))
} }
// ---------------------------------- // ensure returns a wrapped handler that makes sure that the request has the
// helper functions for HTTP handlers // correct method as well as additional method and header checks.
// ---------------------------------- func ensure(
func ensure(method string, handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { method string,
handler func(http.ResponseWriter, *http.Request),
) (wrapped func(http.ResponseWriter, *http.Request)) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
log.Debug("%s %v", r.Method, r.URL) start := time.Now()
m, u := r.Method, r.URL
log.Debug("started %s %s %s", m, r.Host, u)
defer func() { log.Debug("finished %s %s %s in %s", m, r.Host, u, time.Since(start)) }()
if m != method {
aghhttp.Error(r, w, http.StatusMethodNotAllowed, "only method %s is allowed", method)
if r.Method != method {
http.Error(w, "This request must be "+method, http.StatusMethodNotAllowed)
return return
} }
if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete { if modifiesData(m) {
if !ensureContentType(w, r) {
return
}
Context.controlLock.Lock() Context.controlLock.Lock()
defer Context.controlLock.Unlock() defer Context.controlLock.Unlock()
} }
@@ -220,6 +228,42 @@ func ensure(method string, handler func(http.ResponseWriter, *http.Request)) fun
} }
} }
// modifiesData returns true if m is an HTTP method that can modify data.
func modifiesData(m string) (ok bool) {
return m == http.MethodPost || m == http.MethodPut || m == http.MethodDelete
}
// ensureContentType makes sure that the content type of a data-modifying
// request is set correctly. If it is not, ensureContentType writes a response
// to w, and ok is false.
func ensureContentType(w http.ResponseWriter, r *http.Request) (ok bool) {
const statusUnsup = http.StatusUnsupportedMediaType
cType := r.Header.Get(aghhttp.HdrNameContentType)
if r.ContentLength == 0 {
if cType == "" {
return true
}
// Assume that browsers always send a content type when submitting HTML
// forms and require no content type for requests with no body to make
// sure that the request comes from JavaScript.
aghhttp.Error(r, w, statusUnsup, "empty body with content-type %q not allowed", cType)
return false
}
const wantCType = aghhttp.HdrValApplicationJSON
if cType == wantCType {
return true
}
aghhttp.Error(r, w, statusUnsup, "only content-type %s is allowed", wantCType)
return false
}
func ensurePOST(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { func ensurePOST(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
return ensure(http.MethodPost, handler) return ensure(http.MethodPost, handler)
} }
@@ -291,7 +335,7 @@ func handleHTTPSRedirect(w http.ResponseWriter, r *http.Request) (ok bool) {
} }
httpsURL := &url.URL{ httpsURL := &url.URL{
Scheme: schemeHTTPS, Scheme: aghhttp.SchemeHTTPS,
Host: hostPort, Host: hostPort,
Path: r.URL.Path, Path: r.URL.Path,
RawQuery: r.URL.RawQuery, RawQuery: r.URL.RawQuery,
@@ -307,7 +351,7 @@ func handleHTTPSRedirect(w http.ResponseWriter, r *http.Request) (ok bool) {
// //
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin.
originURL := &url.URL{ originURL := &url.URL{
Scheme: schemeHTTP, Scheme: aghhttp.SchemeHTTP,
Host: r.Host, Host: r.Host,
} }
w.Header().Set("Access-Control-Allow-Origin", originURL.String()) w.Header().Set("Access-Control-Allow-Origin", originURL.String())

View File

@@ -59,19 +59,7 @@ func (web *Web) handleInstallGetAddresses(w http.ResponseWriter, r *http.Request
data.Interfaces[iface.Name] = iface data.Interfaces[iface.Name] = iface
} }
w.Header().Set("Content-Type", "application/json") _ = aghhttp.WriteJSONResponse(w, r, data)
err = json.NewEncoder(w).Encode(data)
if err != nil {
aghhttp.Error(
r,
w,
http.StatusInternalServerError,
"Unable to marshal default addresses to json: %s",
err,
)
return
}
} }
type checkConfReqEnt struct { type checkConfReqEnt struct {
@@ -201,13 +189,7 @@ func (web *Web) handleInstallCheckConfig(w http.ResponseWriter, r *http.Request)
resp.StaticIP = handleStaticIP(req.DNS.IP, req.SetStaticIP) resp.StaticIP = handleStaticIP(req.DNS.IP, req.SetStaticIP)
} }
w.Header().Set("Content-Type", "application/json") _ = aghhttp.WriteJSONResponse(w, r, resp)
err = json.NewEncoder(w).Encode(resp)
if err != nil {
aghhttp.Error(r, w, http.StatusInternalServerError, "encoding the response: %s", err)
return
}
} }
// handleStaticIP - handles static IP request // handleStaticIP - handles static IP request
@@ -424,7 +406,7 @@ func (web *Web) handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
return return
} }
u := &User{ u := &webUser{
Name: req.Username, Name: req.Username,
} }
Context.auth.UserAdd(u, req.Password) Context.auth.UserAdd(u, req.Password)
@@ -688,19 +670,7 @@ func (web *Web) handleInstallGetAddressesBeta(w http.ResponseWriter, r *http.Req
data.Interfaces = ifaces data.Interfaces = ifaces
w.Header().Set("Content-Type", "application/json") _ = aghhttp.WriteJSONResponse(w, r, data)
err = json.NewEncoder(w).Encode(data)
if err != nil {
aghhttp.Error(
r,
w,
http.StatusInternalServerError,
"Unable to marshal default addresses to json: %s",
err,
)
return
}
} }
// registerBetaInstallHandlers registers the install handlers for new client // registerBetaInstallHandlers registers the install handlers for new client

View File

@@ -28,8 +28,6 @@ type temporaryError interface {
// Get the latest available version from the Internet // Get the latest available version from the Internet
func handleGetVersionJSON(w http.ResponseWriter, r *http.Request) { func handleGetVersionJSON(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := &versionResponse{} resp := &versionResponse{}
if Context.disableUpdate { if Context.disableUpdate {
resp.Disabled = true resp.Disabled = true
@@ -71,10 +69,7 @@ func handleGetVersionJSON(w http.ResponseWriter, r *http.Request) {
return return
} }
err = json.NewEncoder(w).Encode(resp) _ = aghhttp.WriteJSONResponse(w, r, resp)
if err != nil {
aghhttp.Error(r, w, http.StatusInternalServerError, "writing body: %s", err)
}
} }
// requestVersionInfo sets the VersionInfo field of resp if it can reach the // requestVersionInfo sets the VersionInfo field of resp if it can reach the

View File

@@ -31,7 +31,10 @@ const (
// Called by other modules when configuration is changed // Called by other modules when configuration is changed
func onConfigModified() { func onConfigModified() {
_ = config.write() err := config.write()
if err != nil {
log.Error("writing config: %s", err)
}
} }
// initDNSServer creates an instance of the dnsforward.Server // initDNSServer creates an instance of the dnsforward.Server
@@ -71,11 +74,11 @@ func initDNSServer() (err error) {
} }
Context.queryLog = querylog.New(conf) Context.queryLog = querylog.New(conf)
filterConf := config.DNS.DnsfilterConf Context.filters, err = filtering.New(config.DNS.DnsfilterConf, nil)
filterConf.EtcHosts = Context.etcHosts if err != nil {
filterConf.ConfigModified = onConfigModified // Don't wrap the error, since it's informative enough as is.
filterConf.HTTPRegister = httpRegister return err
Context.dnsFilter = filtering.New(&filterConf, nil) }
var privateNets netutil.SubnetSet var privateNets netutil.SubnetSet
switch len(config.DNS.PrivateNets) { switch len(config.DNS.PrivateNets) {
@@ -83,13 +86,10 @@ func initDNSServer() (err error) {
// Use an optimized locally-served matcher. // Use an optimized locally-served matcher.
privateNets = netutil.SubnetSetFunc(netutil.IsLocallyServed) privateNets = netutil.SubnetSetFunc(netutil.IsLocallyServed)
case 1: case 1:
var n *net.IPNet privateNets, err = netutil.ParseSubnet(config.DNS.PrivateNets[0])
n, err = netutil.ParseSubnet(config.DNS.PrivateNets[0])
if err != nil { if err != nil {
return fmt.Errorf("preparing the set of private subnets: %w", err) return fmt.Errorf("preparing the set of private subnets: %w", err)
} }
privateNets = n
default: default:
var nets []*net.IPNet var nets []*net.IPNet
nets, err = netutil.ParseSubnets(config.DNS.PrivateNets...) nets, err = netutil.ParseSubnets(config.DNS.PrivateNets...)
@@ -101,15 +101,13 @@ func initDNSServer() (err error) {
} }
p := dnsforward.DNSCreateParams{ p := dnsforward.DNSCreateParams{
DNSFilter: Context.dnsFilter, DNSFilter: Context.filters,
Stats: Context.stats, Stats: Context.stats,
QueryLog: Context.queryLog, QueryLog: Context.queryLog,
PrivateNets: privateNets, PrivateNets: privateNets,
Anonymizer: anonymizer, Anonymizer: anonymizer,
LocalDomain: config.DHCP.LocalDomainName, LocalDomain: config.DHCP.LocalDomainName,
} DHCPServer: Context.dhcpServer,
if Context.dhcpServer != nil {
p.DHCPServer = Context.dhcpServer
} }
Context.dnsServer, err = dnsforward.NewServer(p) Context.dnsServer, err = dnsforward.NewServer(p)
@@ -143,7 +141,6 @@ func initDNSServer() (err error) {
Context.whois = initWHOIS(&Context.clients) Context.whois = initWHOIS(&Context.clients)
} }
Context.filters.Init()
return nil return nil
} }
@@ -335,9 +332,12 @@ func getDNSEncryption() (de dnsEncryption) {
// applyAdditionalFiltering adds additional client information and settings if // applyAdditionalFiltering adds additional client information and settings if
// the client has them. // the client has them.
func applyAdditionalFiltering(clientIP net.IP, clientID string, setts *filtering.Settings) { func applyAdditionalFiltering(clientIP net.IP, clientID string, setts *filtering.Settings) {
Context.dnsFilter.ApplyBlockedServices(setts, nil, true) // pref is a prefix for logging messages around the scope.
const pref = "applying filters"
log.Debug("looking up settings for client with ip %s and clientid %q", clientIP, clientID) Context.filters.ApplyBlockedServices(setts, nil)
log.Debug("%s: looking for client with ip %s and clientid %q", pref, clientIP, clientID)
if clientIP == nil { if clientIP == nil {
return return
@@ -349,16 +349,16 @@ func applyAdditionalFiltering(clientIP net.IP, clientID string, setts *filtering
if !ok { if !ok {
c, ok = Context.clients.Find(clientIP.String()) c, ok = Context.clients.Find(clientIP.String())
if !ok { if !ok {
log.Debug("client with ip %s and clientid %q not found", clientIP, clientID) log.Debug("%s: no clients with ip %s and clientid %q", pref, clientIP, clientID)
return return
} }
} }
log.Debug("using settings for client %q with ip %s and clientid %q", c.Name, clientIP, clientID) log.Debug("%s: using settings for client %q (%s; %q)", pref, c.Name, clientIP, clientID)
if c.UseOwnBlockedServices { if c.UseOwnBlockedServices {
Context.dnsFilter.ApplyBlockedServices(setts, c.BlockedServices, false) Context.filters.ApplyBlockedServices(setts, c.BlockedServices)
} }
setts.ClientName = c.Name setts.ClientName = c.Name
@@ -381,7 +381,7 @@ func startDNSServer() error {
return fmt.Errorf("unable to start forwarding DNS server: Already running") return fmt.Errorf("unable to start forwarding DNS server: Already running")
} }
enableFiltersLocked(false) Context.filters.EnableFilters(false)
Context.clients.Start() Context.clients.Start()
@@ -390,7 +390,6 @@ func startDNSServer() error {
return fmt.Errorf("couldn't start forwarding DNS server: %w", err) return fmt.Errorf("couldn't start forwarding DNS server: %w", err)
} }
Context.dnsFilter.Start()
Context.filters.Start() Context.filters.Start()
Context.stats.Start() Context.stats.Start()
Context.queryLog.Start() Context.queryLog.Start()
@@ -449,10 +448,7 @@ func closeDNSServer() {
Context.dnsServer = nil Context.dnsServer = nil
} }
if Context.dnsFilter != nil { Context.filters.Close()
Context.dnsFilter.Close()
Context.dnsFilter = nil
}
if Context.stats != nil { if Context.stats != nil {
err := Context.stats.Close() err := Context.stats.Close()
@@ -469,7 +465,5 @@ func closeDNSServer() {
Context.queryLog = nil Context.queryLog = nil
} }
Context.filters.Close() log.Debug("all dns modules are closed")
log.Debug("Closed all DNS modules")
} }

View File

@@ -20,6 +20,7 @@ import (
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghalg" "github.com/AdguardTeam/AdGuardHome/internal/aghalg"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/aghnet" "github.com/AdguardTeam/AdGuardHome/internal/aghnet"
"github.com/AdguardTeam/AdGuardHome/internal/aghos" "github.com/AdguardTeam/AdGuardHome/internal/aghos"
"github.com/AdguardTeam/AdGuardHome/internal/aghtls" "github.com/AdguardTeam/AdGuardHome/internal/aghtls"
@@ -33,6 +34,7 @@ import (
"github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
"github.com/AdguardTeam/golibs/netutil" "github.com/AdguardTeam/golibs/netutil"
"golang.org/x/exp/slices"
"gopkg.in/natefinch/lumberjack.v2" "gopkg.in/natefinch/lumberjack.v2"
) )
@@ -52,10 +54,9 @@ type homeContext struct {
dnsServer *dnsforward.Server // DNS module dnsServer *dnsforward.Server // DNS module
rdns *RDNS // rDNS module rdns *RDNS // rDNS module
whois *WHOIS // WHOIS module whois *WHOIS // WHOIS module
dnsFilter *filtering.DNSFilter // DNS filtering module
dhcpServer dhcpd.Interface // DHCP module dhcpServer dhcpd.Interface // DHCP module
auth *Auth // HTTP authentication module auth *Auth // HTTP authentication module
filters Filtering // DNS filtering module filters *filtering.DNSFilter // DNS filtering module
web *Web // Web (HTTP, HTTPS) module web *Web // Web (HTTP, HTTPS) module
tls *TLSMod // TLS module tls *TLSMod // TLS module
// etcHosts is an IP-hostname pairs set taken from system configuration // etcHosts is an IP-hostname pairs set taken from system configuration
@@ -140,7 +141,12 @@ func setupContext(args options) {
checkPermissions() checkPermissions()
} }
initConfig() switch version.Channel() {
case version.ChannelEdge, version.ChannelDevelopment:
config.BetaBindPort = 3001
default:
// Go on.
}
Context.tlsRoots = LoadSystemRootCAs() Context.tlsRoots = LoadSystemRootCAs()
Context.transport = &http.Transport{ Context.transport = &http.Transport{
@@ -265,6 +271,15 @@ func setupHostsContainer() (err error) {
} }
func setupConfig(args options) (err error) { func setupConfig(args options) (err error) {
config.DNS.DnsfilterConf.EtcHosts = Context.etcHosts
config.DNS.DnsfilterConf.ConfigModified = onConfigModified
config.DNS.DnsfilterConf.HTTPRegister = httpRegister
config.DNS.DnsfilterConf.DataDir = Context.getDataDir()
config.DNS.DnsfilterConf.Filters = slices.Clone(config.Filters)
config.DNS.DnsfilterConf.WhitelistFilters = slices.Clone(config.WhitelistFilters)
config.DNS.DnsfilterConf.UserRules = slices.Clone(config.UserRules)
config.DNS.DnsfilterConf.HTTPClient = Context.client
config.DHCP.WorkDir = Context.workDir config.DHCP.WorkDir = Context.workDir
config.DHCP.HTTPRegister = httpRegister config.DHCP.HTTPRegister = httpRegister
config.DHCP.ConfigModified = onConfigModified config.DHCP.ConfigModified = onConfigModified
@@ -384,8 +399,6 @@ func fatalOnError(err error) {
// run configures and starts AdGuard Home. // run configures and starts AdGuard Home.
func run(args options, clientBuildFS fs.FS) { func run(args options, clientBuildFS fs.FS) {
var err error
// configure config filename // configure config filename
initConfigFilename(args) initConfigFilename(args)
@@ -396,7 +409,7 @@ func run(args options, clientBuildFS fs.FS) {
configureLogger(args) configureLogger(args)
// Print the first message after logger is configured. // Print the first message after logger is configured.
log.Println(version.Full()) log.Info(version.Full())
log.Debug("current working directory is %s", Context.workDir) log.Debug("current working directory is %s", Context.workDir)
if args.runningAsService { if args.runningAsService {
log.Info("AdGuard Home is running as a service") log.Info("AdGuard Home is running as a service")
@@ -404,7 +417,7 @@ func run(args options, clientBuildFS fs.FS) {
setupContext(args) setupContext(args)
err = configureOS(config) err := configureOS(config)
fatalOnError(err) fatalOnError(err)
// clients package uses filtering package's static data (filtering.BlockedSvcKnown()), // clients package uses filtering package's static data (filtering.BlockedSvcKnown()),
@@ -442,9 +455,9 @@ func run(args options, clientBuildFS fs.FS) {
sessFilename := filepath.Join(Context.getDataDir(), "sessions.db") sessFilename := filepath.Join(Context.getDataDir(), "sessions.db")
GLMode = args.glinetMode GLMode = args.glinetMode
var arl *authRateLimiter var rateLimiter *authRateLimiter
if config.AuthAttempts > 0 && config.AuthBlockMin > 0 { if config.AuthAttempts > 0 && config.AuthBlockMin > 0 {
arl = newAuthRateLimiter( rateLimiter = newAuthRateLimiter(
time.Duration(config.AuthBlockMin)*time.Minute, time.Duration(config.AuthBlockMin)*time.Minute,
config.AuthAttempts, config.AuthAttempts,
) )
@@ -456,7 +469,7 @@ func run(args options, clientBuildFS fs.FS) {
sessFilename, sessFilename,
config.Users, config.Users,
config.WebSessionTTLHours*60*60, config.WebSessionTTLHours*60*60,
arl, rateLimiter,
) )
if Context.auth == nil { if Context.auth == nil {
log.Fatalf("Couldn't initialize Auth module") log.Fatalf("Couldn't initialize Auth module")
@@ -763,12 +776,12 @@ func printHTTPAddresses(proto string) {
} }
port := config.BindPort port := config.BindPort
if proto == schemeHTTPS { if proto == aghhttp.SchemeHTTPS {
port = tlsConf.PortHTTPS port = tlsConf.PortHTTPS
} }
// TODO(e.burkov): Inspect and perhaps merge with the previous condition. // TODO(e.burkov): Inspect and perhaps merge with the previous condition.
if proto == schemeHTTPS && tlsConf.ServerName != "" { if proto == aghhttp.SchemeHTTPS && tlsConf.ServerName != "" {
printWebAddrs(proto, tlsConf.ServerName, tlsConf.PortHTTPS, 0) printWebAddrs(proto, tlsConf.ServerName, tlsConf.PortHTTPS, 0)
return return

View File

@@ -1,10 +1,8 @@
package home package home
import ( import (
"fmt" "encoding/json"
"io"
"net/http" "net/http"
"strings"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
@@ -51,43 +49,35 @@ var allowedLanguages = stringutil.NewSet(
"zh-tw", "zh-tw",
) )
func handleI18nCurrentLanguage(w http.ResponseWriter, _ *http.Request) { // languageJSON is the JSON structure for language requests and responses.
w.Header().Set("Content-Type", "text/plain") type languageJSON struct {
log.Printf("config.Language is %s", config.Language) Language string `json:"language"`
_, err := fmt.Fprintf(w, "%s\n", config.Language) }
if err != nil {
msg := fmt.Sprintf("Unable to write response json: %s", err)
log.Println(msg)
http.Error(w, msg, http.StatusInternalServerError)
return func handleI18nCurrentLanguage(w http.ResponseWriter, r *http.Request) {
} log.Printf("home: language is %s", config.Language)
_ = aghhttp.WriteJSONResponse(w, r, &languageJSON{
Language: config.Language,
})
} }
func handleI18nChangeLanguage(w http.ResponseWriter, r *http.Request) { func handleI18nChangeLanguage(w http.ResponseWriter, r *http.Request) {
// This use of ReadAll is safe, because request's body is now limited. if aghhttp.WriteTextPlainDeprecated(w, r) {
body, err := io.ReadAll(r.Body) return
}
langReq := &languageJSON{}
err := json.NewDecoder(r.Body).Decode(langReq)
if err != nil { if err != nil {
msg := fmt.Sprintf("failed to read request body: %s", err) aghhttp.Error(r, w, http.StatusInternalServerError, "reading req: %s", err)
log.Println(msg)
http.Error(w, msg, http.StatusBadRequest)
return return
} }
language := strings.TrimSpace(string(body)) lang := langReq.Language
if language == "" { if !allowedLanguages.Has(lang) {
msg := "empty language specified" aghhttp.Error(r, w, http.StatusBadRequest, "unknown language: %q", lang)
log.Println(msg)
http.Error(w, msg, http.StatusBadRequest)
return
}
if !allowedLanguages.Has(language) {
msg := fmt.Sprintf("unknown language specified: %s", language)
log.Println(msg)
http.Error(w, msg, http.StatusBadRequest)
return return
} }
@@ -96,7 +86,8 @@ func handleI18nChangeLanguage(w http.ResponseWriter, r *http.Request) {
config.Lock() config.Lock()
defer config.Unlock() defer config.Unlock()
config.Language = language config.Language = lang
log.Printf("home: language is set to %s", lang)
}() }()
onConfigModified() onConfigModified()

View File

@@ -8,6 +8,7 @@ import (
"net/url" "net/url"
"path" "path"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/dnsforward" "github.com/AdguardTeam/AdGuardHome/internal/dnsforward"
"github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
@@ -82,7 +83,7 @@ func encodeMobileConfig(d *dnsSettings, clientID string) ([]byte, error) {
case dnsProtoHTTPS: case dnsProtoHTTPS:
dspName = fmt.Sprintf("%s DoH", d.ServerName) dspName = fmt.Sprintf("%s DoH", d.ServerName)
u := &url.URL{ u := &url.URL{
Scheme: schemeHTTPS, Scheme: aghhttp.SchemeHTTPS,
Host: d.ServerName, Host: d.ServerName,
Path: path.Join("/dns-query", clientID), Path: path.Join("/dns-query", clientID),
} }

View File

@@ -11,6 +11,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/aghos" "github.com/AdguardTeam/AdGuardHome/internal/aghos"
"github.com/AdguardTeam/AdGuardHome/internal/version" "github.com/AdguardTeam/AdGuardHome/internal/version"
"github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/errors"
@@ -175,7 +176,8 @@ func handleServiceControlAction(opts options, clientBuildFS fs.FS) {
chooseSystem() chooseSystem()
action := opts.serviceControlAction action := opts.serviceControlAction
log.Printf("service: control action: %s", action) log.Info(version.Full())
log.Info("service: control action: %s", action)
if action == "reload" { if action == "reload" {
sendSigReload() sendSigReload()
@@ -277,7 +279,7 @@ AdGuard Home is successfully installed and will automatically start on boot.
There are a few more things that must be configured before you can use it. There are a few more things that must be configured before you can use it.
Click on the link below and follow the Installation Wizard steps to finish setup. Click on the link below and follow the Installation Wizard steps to finish setup.
AdGuard Home is now available at the following addresses:`) AdGuard Home is now available at the following addresses:`)
printHTTPAddresses(schemeHTTP) printHTTPAddresses(aghhttp.SchemeHTTP)
} }
} }

View File

@@ -680,8 +680,6 @@ func unmarshalTLS(r *http.Request) (tlsConfigSettingsExt, error) {
} }
func marshalTLS(w http.ResponseWriter, r *http.Request, data tlsConfig) { func marshalTLS(w http.ResponseWriter, r *http.Request, data tlsConfig) {
w.Header().Set("Content-Type", "application/json")
if data.CertificateChain != "" { if data.CertificateChain != "" {
encoded := base64.StdEncoding.EncodeToString([]byte(data.CertificateChain)) encoded := base64.StdEncoding.EncodeToString([]byte(data.CertificateChain))
data.CertificateChain = encoded data.CertificateChain = encoded
@@ -692,16 +690,7 @@ func marshalTLS(w http.ResponseWriter, r *http.Request, data tlsConfig) {
data.PrivateKey = "" data.PrivateKey = ""
} }
err := json.NewEncoder(w).Encode(data) _ = aghhttp.WriteJSONResponse(w, r, data)
if err != nil {
aghhttp.Error(
r,
w,
http.StatusInternalServerError,
"Failed to marshal json with TLS status: %s",
err,
)
}
} }
// registerWebHandlers registers HTTP handlers for TLS configuration // registerWebHandlers registers HTTP handlers for TLS configuration

View File

@@ -278,11 +278,11 @@ func upgradeSchema4to5(diskConf yobj) error {
log.Fatalf("Can't use password \"%s\": bcrypt.GenerateFromPassword: %s", passStr, err) log.Fatalf("Can't use password \"%s\": bcrypt.GenerateFromPassword: %s", passStr, err)
return nil return nil
} }
u := User{ u := webUser{
Name: nameStr, Name: nameStr,
PasswordHash: string(hash), PasswordHash: string(hash),
} }
users := []User{u} users := []webUser{u}
diskConf["users"] = users diskConf["users"] = users
return nil return nil
} }

View File

@@ -4,6 +4,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/filtering"
"github.com/AdguardTeam/golibs/testutil" "github.com/AdguardTeam/golibs/testutil"
"github.com/AdguardTeam/golibs/timeutil" "github.com/AdguardTeam/golibs/timeutil"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -160,7 +161,7 @@ func assertEqualExcept(t *testing.T, oldConf, newConf yobj, oldKeys, newKeys []s
} }
func testDiskConf(schemaVersion int) (diskConf yobj) { func testDiskConf(schemaVersion int) (diskConf yobj) {
filters := []filter{{ filters := []filtering.FilterYAML{{
URL: "https://filters.adtidy.org/android/filters/111_optimized.txt", URL: "https://filters.adtidy.org/android/filters/111_optimized.txt",
Name: "Latvian filter", Name: "Latvian filter",
RulesCount: 100, RulesCount: 100,

View File

@@ -9,6 +9,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/aghnet" "github.com/AdguardTeam/AdGuardHome/internal/aghnet"
"github.com/AdguardTeam/AdGuardHome/internal/aghtls" "github.com/AdguardTeam/AdGuardHome/internal/aghtls"
"github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/errors"
@@ -19,12 +20,6 @@ import (
"golang.org/x/net/http2/h2c" "golang.org/x/net/http2/h2c"
) )
// HTTP scheme constants.
const (
schemeHTTP = "http"
schemeHTTPS = "https"
)
const ( const (
// readTimeout is the maximum duration for reading the entire request, // readTimeout is the maximum duration for reading the entire request,
// including the body. // including the body.
@@ -166,7 +161,7 @@ func (web *Web) Start() {
// this loop is used as an ability to change listening host and/or port // this loop is used as an ability to change listening host and/or port
for !web.httpsServer.shutdown { for !web.httpsServer.shutdown {
printHTTPAddresses(schemeHTTP) printHTTPAddresses(aghhttp.SchemeHTTP)
errs := make(chan error, 2) errs := make(chan error, 2)
// Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies. // Use an h2c handler to support unencrypted HTTP/2, e.g. for proxies.
@@ -286,7 +281,7 @@ func (web *Web) tlsServerLoop() {
WriteTimeout: web.conf.WriteTimeout, WriteTimeout: web.conf.WriteTimeout,
} }
printHTTPAddresses(schemeHTTPS) printHTTPAddresses(aghhttp.SchemeHTTPS)
err := web.httpsServer.server.ListenAndServeTLS("", "") err := web.httpsServer.server.ListenAndServeTLS("", "")
if err != http.ErrServerClosed { if err != http.ErrServerClosed {
cleanupAlways() cleanupAlways()

View File

@@ -11,29 +11,32 @@ import (
"net/netip" "net/netip"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/v1/websvc" "github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
) )
// Main is the entry point of application. // Main is the entry point of application.
func Main(clientBuildFS fs.FS) { func Main(clientBuildFS fs.FS) {
// # Initial Configuration // Initial Configuration
start := time.Now() start := time.Now()
rand.Seed(start.UnixNano()) rand.Seed(start.UnixNano())
// TODO(a.garipov): Set up logging. // TODO(a.garipov): Set up logging.
// # Web Service // Web Service
// TODO(a.garipov): Use in the Web service. // TODO(a.garipov): Use in the Web service.
_ = clientBuildFS _ = clientBuildFS
// TODO(a.garipov): Make configurable. // TODO(a.garipov): Make configurable.
web := websvc.New(&websvc.Config{ web := websvc.New(&websvc.Config{
Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:3001")}, // TODO(a.garipov): Use an actual implementation.
Start: start, ConfigManager: nil,
Timeout: 60 * time.Second, Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:3001")},
Start: start,
Timeout: 60 * time.Second,
ForceHTTPS: false,
}) })
err := web.Start() err := web.Start()

View File

@@ -4,7 +4,7 @@ import (
"os" "os"
"github.com/AdguardTeam/AdGuardHome/internal/aghos" "github.com/AdguardTeam/AdGuardHome/internal/aghos"
"github.com/AdguardTeam/AdGuardHome/internal/v1/agh" "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
) )

View File

@@ -9,9 +9,10 @@ import (
"fmt" "fmt"
"net" "net"
"net/netip" "net/netip"
"sync/atomic"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/v1/agh" "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
// TODO(a.garipov): Add a “dnsproxy proxy” package to shield us from changes // TODO(a.garipov): Add a “dnsproxy proxy” package to shield us from changes
// and replacement of module dnsproxy. // and replacement of module dnsproxy.
"github.com/AdguardTeam/dnsproxy/proxy" "github.com/AdguardTeam/dnsproxy/proxy"
@@ -47,6 +48,14 @@ type Config struct {
// Service is the AdGuard Home DNS service. A nil *Service is a valid // Service is the AdGuard Home DNS service. A nil *Service is a valid
// [agh.Service] that does nothing. // [agh.Service] that does nothing.
type Service struct { type Service struct {
// running is an atomic boolean value. Keep it the first value in the
// struct to ensure atomic alignment. 0 means that the service is not
// running, 1 means that it is running.
//
// TODO(a.garipov): Use [atomic.Bool] in Go 1.19 or get rid of it
// completely.
running uint64
proxy *proxy.Proxy proxy *proxy.Proxy
bootstraps []string bootstraps []string
upstreams []string upstreams []string
@@ -160,6 +169,17 @@ func (svc *Service) Start() (err error) {
return nil return nil
} }
defer func() {
// TODO(a.garipov): [proxy.Proxy.Start] doesn't actually have any way to
// tell when all servers are actually up, so at best this is merely an
// assumption.
if err != nil {
atomic.StoreUint64(&svc.running, 0)
} else {
atomic.StoreUint64(&svc.running, 1)
}
}()
return svc.proxy.Start() return svc.proxy.Start()
} }
@@ -173,13 +193,27 @@ func (svc *Service) Shutdown(ctx context.Context) (err error) {
return svc.proxy.Stop() return svc.proxy.Stop()
} }
// Config returns the current configuration of the web service. // Config returns the current configuration of the web service. Config must not
// be called simultaneously with Start. If svc was initialized with ":0"
// addresses, addrs will not return the actual bound ports until Start is
// finished.
func (svc *Service) Config() (c *Config) { func (svc *Service) Config() (c *Config) {
// TODO(a.garipov): Do we need to get the TCP addresses separately? // TODO(a.garipov): Do we need to get the TCP addresses separately?
udpAddrs := svc.proxy.Addrs(proxy.ProtoUDP)
addrs := make([]netip.AddrPort, len(udpAddrs)) var addrs []netip.AddrPort
for i, a := range udpAddrs { if atomic.LoadUint64(&svc.running) == 1 {
addrs[i] = a.(*net.UDPAddr).AddrPort() udpAddrs := svc.proxy.Addrs(proxy.ProtoUDP)
addrs = make([]netip.AddrPort, len(udpAddrs))
for i, a := range udpAddrs {
addrs[i] = a.(*net.UDPAddr).AddrPort()
}
} else {
conf := svc.proxy.Config
udpAddrs := conf.UDPListenAddr
addrs = make([]netip.AddrPort, len(udpAddrs))
for i, a := range udpAddrs {
addrs[i] = a.AddrPort()
}
} }
c = &Config{ c = &Config{

View File

@@ -7,7 +7,7 @@ import (
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghtest" "github.com/AdguardTeam/AdGuardHome/internal/aghtest"
"github.com/AdguardTeam/AdGuardHome/internal/v1/dnssvc" "github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc"
"github.com/AdguardTeam/dnsproxy/upstream" "github.com/AdguardTeam/dnsproxy/upstream"
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"

View File

@@ -0,0 +1,84 @@
package websvc
import (
"encoding/json"
"fmt"
"net/http"
"net/netip"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc"
)
// DNS Settings Handlers
// ReqPatchSettingsDNS describes the request to the PATCH /api/v1/settings/dns
// HTTP API.
type ReqPatchSettingsDNS struct {
// TODO(a.garipov): Add more as we go.
Addresses []netip.AddrPort `json:"addresses"`
BootstrapServers []string `json:"bootstrap_servers"`
UpstreamServers []string `json:"upstream_servers"`
UpstreamTimeout JSONDuration `json:"upstream_timeout"`
}
// HTTPAPIDNSSettings are the DNS settings as used by the HTTP API. See the
// DnsSettings object in the OpenAPI specification.
type HTTPAPIDNSSettings struct {
// TODO(a.garipov): Add more as we go.
Addresses []netip.AddrPort `json:"addresses"`
BootstrapServers []string `json:"bootstrap_servers"`
UpstreamServers []string `json:"upstream_servers"`
UpstreamTimeout JSONDuration `json:"upstream_timeout"`
}
// handlePatchSettingsDNS is the handler for the PATCH /api/v1/settings/dns HTTP
// API.
func (svc *Service) handlePatchSettingsDNS(w http.ResponseWriter, r *http.Request) {
req := &ReqPatchSettingsDNS{
Addresses: []netip.AddrPort{},
BootstrapServers: []string{},
UpstreamServers: []string{},
}
// TODO(a.garipov): Validate nulls and proper JSON patch.
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
writeJSONErrorResponse(w, r, fmt.Errorf("decoding: %w", err))
return
}
newConf := &dnssvc.Config{
Addresses: req.Addresses,
BootstrapServers: req.BootstrapServers,
UpstreamServers: req.UpstreamServers,
UpstreamTimeout: time.Duration(req.UpstreamTimeout),
}
ctx := r.Context()
err = svc.confMgr.UpdateDNS(ctx, newConf)
if err != nil {
writeJSONErrorResponse(w, r, fmt.Errorf("updating: %w", err))
return
}
newSvc := svc.confMgr.DNS()
err = newSvc.Start()
if err != nil {
writeJSONErrorResponse(w, r, fmt.Errorf("starting new service: %w", err))
return
}
writeJSONOKResponse(w, r, &HTTPAPIDNSSettings{
Addresses: newConf.Addresses,
BootstrapServers: newConf.BootstrapServers,
UpstreamServers: newConf.UpstreamServers,
UpstreamTimeout: JSONDuration(newConf.UpstreamTimeout),
})
}

View File

@@ -0,0 +1,68 @@
package websvc_test
import (
"context"
"encoding/json"
"net/http"
"net/netip"
"net/url"
"sync/atomic"
"testing"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/aghtest"
"github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc"
"github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestService_HandlePatchSettingsDNS(t *testing.T) {
wantDNS := &websvc.HTTPAPIDNSSettings{
Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.1.1:53")},
BootstrapServers: []string{"1.0.0.1"},
UpstreamServers: []string{"1.1.1.1"},
UpstreamTimeout: websvc.JSONDuration(2 * time.Second),
}
// TODO(a.garipov): Use [atomic.Bool] in Go 1.19.
var numStarted uint64
confMgr := newConfigManager()
confMgr.onDNS = func() (s websvc.ServiceWithConfig[*dnssvc.Config]) {
return &aghtest.ServiceWithConfig[*dnssvc.Config]{
OnStart: func() (err error) {
atomic.AddUint64(&numStarted, 1)
return nil
},
OnShutdown: func(_ context.Context) (err error) { panic("not implemented") },
OnConfig: func() (c *dnssvc.Config) { panic("not implemented") },
}
}
confMgr.onUpdateDNS = func(ctx context.Context, c *dnssvc.Config) (err error) {
return nil
}
_, addr := newTestServer(t, confMgr)
u := &url.URL{
Scheme: "http",
Host: addr.String(),
Path: websvc.PathV1SettingsDNS,
}
req := jobj{
"addresses": wantDNS.Addresses,
"bootstrap_servers": wantDNS.BootstrapServers,
"upstream_servers": wantDNS.UpstreamServers,
"upstream_timeout": wantDNS.UpstreamTimeout,
}
respBody := httpPatch(t, u, req, http.StatusOK)
resp := &websvc.HTTPAPIDNSSettings{}
err := json.Unmarshal(respBody, resp)
require.NoError(t, err)
assert.Equal(t, uint64(1), numStarted)
assert.Equal(t, wantDNS, resp)
assert.Equal(t, wantDNS, resp)
}

View File

@@ -0,0 +1,109 @@
package websvc
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"time"
"github.com/AdguardTeam/golibs/log"
)
// HTTP Settings Handlers
// ReqPatchSettingsHTTP describes the request to the PATCH /api/v1/settings/http
// HTTP API.
type ReqPatchSettingsHTTP struct {
// TODO(a.garipov): Add more as we go.
//
// TODO(a.garipov): Add wait time.
Addresses []netip.AddrPort `json:"addresses"`
SecureAddresses []netip.AddrPort `json:"secure_addresses"`
Timeout JSONDuration `json:"timeout"`
}
// HTTPAPIHTTPSettings are the HTTP settings as used by the HTTP API. See the
// HttpSettings object in the OpenAPI specification.
type HTTPAPIHTTPSettings struct {
// TODO(a.garipov): Add more as we go.
Addresses []netip.AddrPort `json:"addresses"`
SecureAddresses []netip.AddrPort `json:"secure_addresses"`
Timeout JSONDuration `json:"timeout"`
ForceHTTPS bool `json:"force_https"`
}
// handlePatchSettingsHTTP is the handler for the PATCH /api/v1/settings/http
// HTTP API.
func (svc *Service) handlePatchSettingsHTTP(w http.ResponseWriter, r *http.Request) {
req := &ReqPatchSettingsHTTP{}
// TODO(a.garipov): Validate nulls and proper JSON patch.
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
writeJSONErrorResponse(w, r, fmt.Errorf("decoding: %w", err))
return
}
newConf := &Config{
ConfigManager: svc.confMgr,
TLS: svc.tls,
Addresses: req.Addresses,
SecureAddresses: req.SecureAddresses,
Timeout: time.Duration(req.Timeout),
ForceHTTPS: svc.forceHTTPS,
}
writeJSONOKResponse(w, r, &HTTPAPIHTTPSettings{
Addresses: newConf.Addresses,
SecureAddresses: newConf.SecureAddresses,
Timeout: JSONDuration(newConf.Timeout),
ForceHTTPS: newConf.ForceHTTPS,
})
cancelUpd := func() {}
updCtx := context.Background()
ctx := r.Context()
if deadline, ok := ctx.Deadline(); ok {
updCtx, cancelUpd = context.WithDeadline(updCtx, deadline)
}
// Launch the new HTTP service in a separate goroutine to let this handler
// finish and thus, this server to shutdown.
go func() {
defer cancelUpd()
updErr := svc.confMgr.UpdateWeb(updCtx, newConf)
if updErr != nil {
writeJSONErrorResponse(w, r, fmt.Errorf("updating: %w", updErr))
return
}
// TODO(a.garipov): Consider better ways to do this.
const maxUpdDur = 10 * time.Second
updStart := time.Now()
var newSvc ServiceWithConfig[*Config]
for newSvc = svc.confMgr.Web(); newSvc == svc; {
if time.Since(updStart) >= maxUpdDur {
log.Error("websvc: failed to update svc after %s", maxUpdDur)
return
}
log.Debug("websvc: waiting for new websvc to be configured")
time.Sleep(1 * time.Second)
}
updErr = newSvc.Start()
if updErr != nil {
log.Error("websvc: new svc failed to start with error: %s", updErr)
}
}()
}

View File

@@ -0,0 +1,62 @@
package websvc_test
import (
"context"
"crypto/tls"
"encoding/json"
"net/http"
"net/netip"
"net/url"
"testing"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestService_HandlePatchSettingsHTTP(t *testing.T) {
wantWeb := &websvc.HTTPAPIHTTPSettings{
Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.1.1:80")},
SecureAddresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.1.1:443")},
Timeout: websvc.JSONDuration(10 * time.Second),
ForceHTTPS: false,
}
confMgr := newConfigManager()
confMgr.onWeb = func() (s websvc.ServiceWithConfig[*websvc.Config]) {
return websvc.New(&websvc.Config{
TLS: &tls.Config{
Certificates: []tls.Certificate{{}},
},
Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:80")},
SecureAddresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:443")},
Timeout: 5 * time.Second,
ForceHTTPS: true,
})
}
confMgr.onUpdateWeb = func(ctx context.Context, c *websvc.Config) (err error) {
return nil
}
_, addr := newTestServer(t, confMgr)
u := &url.URL{
Scheme: "http",
Host: addr.String(),
Path: websvc.PathV1SettingsHTTP,
}
req := jobj{
"addresses": wantWeb.Addresses,
"secure_addresses": wantWeb.SecureAddresses,
"timeout": wantWeb.Timeout,
"force_https": wantWeb.ForceHTTPS,
}
respBody := httpPatch(t, u, req, http.StatusOK)
resp := &websvc.HTTPAPIHTTPSettings{}
err := json.Unmarshal(respBody, resp)
require.NoError(t, err)
assert.Equal(t, wantWeb, resp)
}

View File

@@ -0,0 +1,143 @@
package websvc
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/golibs/log"
)
// JSON Utilities
// nsecPerMsec is the number of nanoseconds in a millisecond.
const nsecPerMsec = float64(time.Millisecond / time.Nanosecond)
// JSONDuration is a time.Duration that can be decoded from JSON and encoded
// into JSON according to our API conventions.
type JSONDuration time.Duration
// type check
var _ json.Marshaler = JSONDuration(0)
// MarshalJSON implements the json.Marshaler interface for JSONDuration. err is
// always nil.
func (d JSONDuration) MarshalJSON() (b []byte, err error) {
msec := float64(time.Duration(d)) / nsecPerMsec
b = strconv.AppendFloat(nil, msec, 'f', -1, 64)
return b, nil
}
// type check
var _ json.Unmarshaler = (*JSONDuration)(nil)
// UnmarshalJSON implements the json.Marshaler interface for *JSONDuration.
func (d *JSONDuration) UnmarshalJSON(b []byte) (err error) {
if d == nil {
return fmt.Errorf("json duration is nil")
}
msec, err := strconv.ParseFloat(string(b), 64)
if err != nil {
return fmt.Errorf("parsing json time: %w", err)
}
*d = JSONDuration(int64(msec * nsecPerMsec))
return nil
}
// JSONTime is a time.Time that can be decoded from JSON and encoded into JSON
// according to our API conventions.
type JSONTime time.Time
// type check
var _ json.Marshaler = JSONTime{}
// MarshalJSON implements the json.Marshaler interface for JSONTime. err is
// always nil.
func (t JSONTime) MarshalJSON() (b []byte, err error) {
msec := float64(time.Time(t).UnixNano()) / nsecPerMsec
b = strconv.AppendFloat(nil, msec, 'f', -1, 64)
return b, nil
}
// type check
var _ json.Unmarshaler = (*JSONTime)(nil)
// UnmarshalJSON implements the json.Marshaler interface for *JSONTime.
func (t *JSONTime) UnmarshalJSON(b []byte) (err error) {
if t == nil {
return fmt.Errorf("json time is nil")
}
msec, err := strconv.ParseFloat(string(b), 64)
if err != nil {
return fmt.Errorf("parsing json time: %w", err)
}
*t = JSONTime(time.Unix(0, int64(msec*nsecPerMsec)).UTC())
return nil
}
// writeJSONOKResponse writes headers with the code 200 OK, encodes v into w,
// and logs any errors it encounters. r is used to get additional information
// from the request.
func writeJSONOKResponse(w http.ResponseWriter, r *http.Request, v any) {
writeJSONResponse(w, r, v, http.StatusOK)
}
// writeJSONResponse writes headers with code, encodes v into w, and logs any
// errors it encounters. r is used to get additional information from the
// request.
func writeJSONResponse(w http.ResponseWriter, r *http.Request, v any, code int) {
// TODO(a.garipov): Put some of these to a middleware.
h := w.Header()
h.Set(aghhttp.HdrNameContentType, aghhttp.HdrValApplicationJSON)
h.Set(aghhttp.HdrNameServer, aghhttp.UserAgent())
w.WriteHeader(code)
err := json.NewEncoder(w).Encode(v)
if err != nil {
log.Error("websvc: writing resp to %s %s: %s", r.Method, r.URL.Path, err)
}
}
// ErrorCode is the error code as used by the HTTP API. See the ErrorCode
// definition in the OpenAPI specification.
type ErrorCode string
// ErrorCode constants.
//
// TODO(a.garipov): Expand and document codes.
const (
// ErrorCodeTMP000 is the temporary error code used for all errors.
ErrorCodeTMP000 = ""
)
// HTTPAPIErrorResp is the error response as used by the HTTP API. See the
// BadRequestResp, InternalServerErrorResp, and similar objects in the OpenAPI
// specification.
type HTTPAPIErrorResp struct {
Code ErrorCode `json:"code"`
Msg string `json:"msg"`
}
// writeJSONErrorResponse encodes err as a JSON error into w, and logs any
// errors it encounters. r is used to get additional information from the
// request.
func writeJSONErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
log.Error("websvc: %s %s: %s", r.Method, r.URL.Path, err)
writeJSONResponse(w, r, &HTTPAPIErrorResp{
Code: ErrorCodeTMP000,
Msg: err.Error(),
}, http.StatusUnprocessableEntity)
}

View File

@@ -0,0 +1,114 @@
package websvc_test
import (
"encoding/json"
"testing"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
"github.com/AdguardTeam/golibs/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testJSONTime is the JSON time for tests.
var testJSONTime = websvc.JSONTime(time.Unix(1_234_567_890, 123_456_000).UTC())
// testJSONTimeStr is the string with the JSON encoding of testJSONTime.
const testJSONTimeStr = "1234567890123.456"
func TestJSONTime_MarshalJSON(t *testing.T) {
testCases := []struct {
name string
wantErrMsg string
in websvc.JSONTime
want []byte
}{{
name: "unix_zero",
wantErrMsg: "",
in: websvc.JSONTime(time.Unix(0, 0)),
want: []byte("0"),
}, {
name: "empty",
wantErrMsg: "",
in: websvc.JSONTime{},
want: []byte("-6795364578871.345"),
}, {
name: "time",
wantErrMsg: "",
in: testJSONTime,
want: []byte(testJSONTimeStr),
}}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.in.MarshalJSON()
testutil.AssertErrorMsg(t, tc.wantErrMsg, err)
assert.Equal(t, tc.want, got)
})
}
t.Run("json", func(t *testing.T) {
in := &struct {
A websvc.JSONTime
}{
A: testJSONTime,
}
got, err := json.Marshal(in)
require.NoError(t, err)
assert.Equal(t, []byte(`{"A":`+testJSONTimeStr+`}`), got)
})
}
func TestJSONTime_UnmarshalJSON(t *testing.T) {
testCases := []struct {
name string
wantErrMsg string
want websvc.JSONTime
data []byte
}{{
name: "time",
wantErrMsg: "",
want: testJSONTime,
data: []byte(testJSONTimeStr),
}, {
name: "bad",
wantErrMsg: `parsing json time: strconv.ParseFloat: parsing "{}": ` +
`invalid syntax`,
want: websvc.JSONTime{},
data: []byte(`{}`),
}}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var got websvc.JSONTime
err := got.UnmarshalJSON(tc.data)
testutil.AssertErrorMsg(t, tc.wantErrMsg, err)
assert.Equal(t, tc.want, got)
})
}
t.Run("nil", func(t *testing.T) {
err := (*websvc.JSONTime)(nil).UnmarshalJSON([]byte("0"))
require.Error(t, err)
msg := err.Error()
assert.Equal(t, "json time is nil", msg)
})
t.Run("json", func(t *testing.T) {
want := testJSONTime
var got struct {
A websvc.JSONTime
}
err := json.Unmarshal([]byte(`{"A":`+testJSONTimeStr+`}`), &got)
require.NoError(t, err)
assert.Equal(t, want, got.A)
})
}

View File

@@ -0,0 +1,11 @@
package websvc
// Path constants
const (
PathHealthCheck = "/health-check"
PathV1SettingsAll = "/api/v1/settings/all"
PathV1SettingsDNS = "/api/v1/settings/dns"
PathV1SettingsHTTP = "/api/v1/settings/http"
PathV1SystemInfo = "/api/v1/system/info"
)

View File

@@ -0,0 +1,42 @@
package websvc
import (
"net/http"
)
// All Settings Handlers
// RespGetV1SettingsAll describes the response of the GET /api/v1/settings/all
// HTTP API.
type RespGetV1SettingsAll struct {
// TODO(a.garipov): Add more as we go.
DNS *HTTPAPIDNSSettings `json:"dns"`
HTTP *HTTPAPIHTTPSettings `json:"http"`
}
// handleGetSettingsAll is the handler for the GET /api/v1/settings/all HTTP
// API.
func (svc *Service) handleGetSettingsAll(w http.ResponseWriter, r *http.Request) {
dnsSvc := svc.confMgr.DNS()
dnsConf := dnsSvc.Config()
webSvc := svc.confMgr.Web()
httpConf := webSvc.Config()
// TODO(a.garipov): Add all currently supported parameters.
writeJSONOKResponse(w, r, &RespGetV1SettingsAll{
DNS: &HTTPAPIDNSSettings{
Addresses: dnsConf.Addresses,
BootstrapServers: dnsConf.BootstrapServers,
UpstreamServers: dnsConf.UpstreamServers,
UpstreamTimeout: JSONDuration(dnsConf.UpstreamTimeout),
},
HTTP: &HTTPAPIHTTPSettings{
Addresses: httpConf.Addresses,
SecureAddresses: httpConf.SecureAddresses,
Timeout: JSONDuration(httpConf.Timeout),
ForceHTTPS: httpConf.ForceHTTPS,
},
})
}

View File

@@ -0,0 +1,74 @@
package websvc_test
import (
"crypto/tls"
"encoding/json"
"net/http"
"net/netip"
"net/url"
"testing"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc"
"github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestService_HandleGetSettingsAll(t *testing.T) {
// TODO(a.garipov): Add all currently supported parameters.
wantDNS := &websvc.HTTPAPIDNSSettings{
Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:53")},
BootstrapServers: []string{"94.140.14.140", "94.140.14.141"},
UpstreamServers: []string{"94.140.14.14", "1.1.1.1"},
UpstreamTimeout: websvc.JSONDuration(1 * time.Second),
}
wantWeb := &websvc.HTTPAPIHTTPSettings{
Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:80")},
SecureAddresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:443")},
Timeout: websvc.JSONDuration(5 * time.Second),
ForceHTTPS: true,
}
confMgr := newConfigManager()
confMgr.onDNS = func() (s websvc.ServiceWithConfig[*dnssvc.Config]) {
c, err := dnssvc.New(&dnssvc.Config{
Addresses: wantDNS.Addresses,
UpstreamServers: wantDNS.UpstreamServers,
BootstrapServers: wantDNS.BootstrapServers,
UpstreamTimeout: time.Duration(wantDNS.UpstreamTimeout),
})
require.NoError(t, err)
return c
}
confMgr.onWeb = func() (s websvc.ServiceWithConfig[*websvc.Config]) {
return websvc.New(&websvc.Config{
TLS: &tls.Config{
Certificates: []tls.Certificate{{}},
},
Addresses: wantWeb.Addresses,
SecureAddresses: wantWeb.SecureAddresses,
Timeout: time.Duration(wantWeb.Timeout),
ForceHTTPS: true,
})
}
_, addr := newTestServer(t, confMgr)
u := &url.URL{
Scheme: "http",
Host: addr.String(),
Path: websvc.PathV1SettingsAll,
}
body := httpGet(t, u, http.StatusOK)
resp := &websvc.RespGetV1SettingsAll{}
err := json.Unmarshal(body, resp)
require.NoError(t, err)
assert.Equal(t, wantDNS, resp.DNS)
assert.Equal(t, wantWeb, resp.HTTP)
}

View File

@@ -16,20 +16,20 @@ type RespGetV1SystemInfo struct {
Channel string `json:"channel"` Channel string `json:"channel"`
OS string `json:"os"` OS string `json:"os"`
NewVersion string `json:"new_version,omitempty"` NewVersion string `json:"new_version,omitempty"`
Start jsonTime `json:"start"` Start JSONTime `json:"start"`
Version string `json:"version"` Version string `json:"version"`
} }
// handleGetV1SystemInfo is the handler for the GET /api/v1/system/info HTTP // handleGetV1SystemInfo is the handler for the GET /api/v1/system/info HTTP
// API. // API.
func (svc *Service) handleGetV1SystemInfo(w http.ResponseWriter, r *http.Request) { func (svc *Service) handleGetV1SystemInfo(w http.ResponseWriter, r *http.Request) {
writeJSONResponse(w, r, &RespGetV1SystemInfo{ writeJSONOKResponse(w, r, &RespGetV1SystemInfo{
Arch: runtime.GOARCH, Arch: runtime.GOARCH,
Channel: version.Channel(), Channel: version.Channel(),
OS: runtime.GOOS, OS: runtime.GOOS,
// TODO(a.garipov): Fill this when we have an updater. // TODO(a.garipov): Fill this when we have an updater.
NewVersion: "", NewVersion: "",
Start: jsonTime(svc.start), Start: JSONTime(svc.start),
Version: version.Version(), Version: version.Version(),
}) })
} }

View File

@@ -8,16 +8,17 @@ import (
"testing" "testing"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/v1/websvc" "github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestService_handleGetV1SystemInfo(t *testing.T) { func TestService_handleGetV1SystemInfo(t *testing.T) {
_, addr := newTestServer(t) confMgr := newConfigManager()
_, addr := newTestServer(t, confMgr)
u := &url.URL{ u := &url.URL{
Scheme: "http", Scheme: "http",
Host: addr, Host: addr.String(),
Path: websvc.PathV1SystemInfo, Path: websvc.PathV1SystemInfo,
} }

View File

@@ -0,0 +1,31 @@
package websvc
import (
"net"
"sync"
)
// Wait Listener
// waitListener is a wrapper around a listener that also calls wg.Done() on the
// first call to Accept. It is useful in situations where it is important to
// catch the precise moment of the first call to Accept, for example when
// starting an HTTP server.
//
// TODO(a.garipov): Move to aghnet?
type waitListener struct {
net.Listener
firstAcceptWG *sync.WaitGroup
firstAcceptOnce sync.Once
}
// type check
var _ net.Listener = (*waitListener)(nil)
// Accept implements the [net.Listener] interface for *waitListener.
func (l *waitListener) Accept() (conn net.Conn, err error) {
l.firstAcceptOnce.Do(l.firstAcceptWG.Done)
return l.Listener.Accept()
}

View File

@@ -0,0 +1,46 @@
package websvc
import (
"net"
"sync"
"sync/atomic"
"testing"
"github.com/AdguardTeam/AdGuardHome/internal/aghchan"
"github.com/AdguardTeam/AdGuardHome/internal/aghtest"
"github.com/stretchr/testify/assert"
)
func TestWaitListener_Accept(t *testing.T) {
// TODO(a.garipov): use atomic.Bool in Go 1.19.
var numAcceptCalls uint32
var l net.Listener = &aghtest.Listener{
OnAccept: func() (conn net.Conn, err error) {
atomic.AddUint32(&numAcceptCalls, 1)
return nil, nil
},
OnAddr: func() (addr net.Addr) { panic("not implemented") },
OnClose: func() (err error) { panic("not implemented") },
}
wg := &sync.WaitGroup{}
wg.Add(1)
done := make(chan struct{})
go aghchan.MustReceive(done, testTimeout)
go func() {
var wrapper net.Listener = &waitListener{
Listener: l,
firstAcceptWG: wg,
}
_, _ = wrapper.Accept()
}()
wg.Wait()
close(done)
assert.Equal(t, uint32(1), atomic.LoadUint32(&numAcceptCalls))
}

View File

@@ -1,4 +1,7 @@
// Package websvc contains the AdGuard Home web service. // Package websvc contains the AdGuard Home HTTP API service.
//
// NOTE: Packages other than cmd must not import this package, as it imports
// most other packages.
// //
// TODO(a.garipov): Add tests. // TODO(a.garipov): Add tests.
package websvc package websvc
@@ -14,18 +17,46 @@ import (
"sync" "sync"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/v1/agh" "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
"github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc"
"github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
httptreemux "github.com/dimfeld/httptreemux/v5" httptreemux "github.com/dimfeld/httptreemux/v5"
) )
// ServiceWithConfig is an extension of the [agh.Service] interface for services
// that can return their configuration.
//
// TODO(a.garipov): Consider removing this generic interface if we figure out
// how to make it testable in a better way.
type ServiceWithConfig[ConfigType any] interface {
agh.Service
Config() (c ConfigType)
}
// ConfigManager is the configuration manager interface.
type ConfigManager interface {
DNS() (svc ServiceWithConfig[*dnssvc.Config])
Web() (svc ServiceWithConfig[*Config])
UpdateDNS(ctx context.Context, c *dnssvc.Config) (err error)
UpdateWeb(ctx context.Context, c *Config) (err error)
}
// Config is the AdGuard Home web service configuration structure. // Config is the AdGuard Home web service configuration structure.
type Config struct { type Config struct {
// ConfigManager is used to show information about services as well as
// dynamically reconfigure them.
ConfigManager ConfigManager
// TLS is the optional TLS configuration. If TLS is not nil, // TLS is the optional TLS configuration. If TLS is not nil,
// SecureAddresses must not be empty. // SecureAddresses must not be empty.
TLS *tls.Config TLS *tls.Config
// Start is the time of start of AdGuard Home.
Start time.Time
// Addresses are the addresses on which to serve the plain HTTP API. // Addresses are the addresses on which to serve the plain HTTP API.
Addresses []netip.AddrPort Addresses []netip.AddrPort
@@ -33,40 +64,48 @@ type Config struct {
// SecureAddresses is not empty, TLS must not be nil. // SecureAddresses is not empty, TLS must not be nil.
SecureAddresses []netip.AddrPort SecureAddresses []netip.AddrPort
// Start is the time of start of AdGuard Home.
Start time.Time
// Timeout is the timeout for all server operations. // Timeout is the timeout for all server operations.
Timeout time.Duration Timeout time.Duration
// ForceHTTPS tells if all requests to Addresses should be redirected to a
// secure address instead.
//
// TODO(a.garipov): Use; define rules, which address to redirect to.
ForceHTTPS bool
} }
// Service is the AdGuard Home web service. A nil *Service is a valid // Service is the AdGuard Home web service. A nil *Service is a valid
// [agh.Service] that does nothing. // [agh.Service] that does nothing.
type Service struct { type Service struct {
tls *tls.Config confMgr ConfigManager
servers []*http.Server tls *tls.Config
start time.Time start time.Time
timeout time.Duration servers []*http.Server
timeout time.Duration
forceHTTPS bool
} }
// New returns a new properly initialized *Service. If c is nil, svc is a nil // New returns a new properly initialized *Service. If c is nil, svc is a nil
// *Service that does nothing. // *Service that does nothing. The fields of c must not be modified after
// calling New.
func New(c *Config) (svc *Service) { func New(c *Config) (svc *Service) {
if c == nil { if c == nil {
return nil return nil
} }
svc = &Service{ svc = &Service{
tls: c.TLS, confMgr: c.ConfigManager,
start: c.Start, tls: c.TLS,
timeout: c.Timeout, start: c.Start,
timeout: c.Timeout,
forceHTTPS: c.ForceHTTPS,
} }
mux := newMux(svc) mux := newMux(svc)
for _, a := range c.Addresses { for _, a := range c.Addresses {
addr := a.String() addr := a.String()
errLog := log.StdLog("websvc: http: "+addr, log.ERROR) errLog := log.StdLog("websvc: plain http: "+addr, log.ERROR)
svc.servers = append(svc.servers, &http.Server{ svc.servers = append(svc.servers, &http.Server{
Addr: addr, Addr: addr,
Handler: mux, Handler: mux,
@@ -111,6 +150,21 @@ func newMux(svc *Service) (mux *httptreemux.ContextMux) {
method: http.MethodGet, method: http.MethodGet,
path: PathHealthCheck, path: PathHealthCheck,
isJSON: false, isJSON: false,
}, {
handler: svc.handleGetSettingsAll,
method: http.MethodGet,
path: PathV1SettingsAll,
isJSON: true,
}, {
handler: svc.handlePatchSettingsDNS,
method: http.MethodPatch,
path: PathV1SettingsDNS,
isJSON: true,
}, {
handler: svc.handlePatchSettingsHTTP,
method: http.MethodPatch,
path: PathV1SettingsHTTP,
isJSON: true,
}, { }, {
handler: svc.handleGetV1SystemInfo, handler: svc.handleGetV1SystemInfo,
method: http.MethodGet, method: http.MethodGet,
@@ -119,29 +173,41 @@ func newMux(svc *Service) (mux *httptreemux.ContextMux) {
}} }}
for _, r := range routes { for _, r := range routes {
var h http.HandlerFunc
if r.isJSON { if r.isJSON {
// TODO(a.garipov): Consider using httptreemux's MiddlewareFunc. mux.Handle(r.method, r.path, jsonMw(r.handler))
h = jsonMw(r.handler)
} else { } else {
h = r.handler mux.Handle(r.method, r.path, r.handler)
} }
mux.Handle(r.method, r.path, h)
} }
return mux return mux
} }
// Addrs returns all addresses on which this server serves the HTTP API. Addrs // addrs returns all addresses on which this server serves the HTTP API. addrs
// must not be called until Start returns. // must not be called simultaneously with Start. If svc was initialized with
func (svc *Service) Addrs() (addrs []string) { // ":0" addresses, addrs will not return the actual bound ports until Start is
addrs = make([]string, 0, len(svc.servers)) // finished.
func (svc *Service) addrs() (addrs, secureAddrs []netip.AddrPort) {
for _, srv := range svc.servers { for _, srv := range svc.servers {
addrs = append(addrs, srv.Addr) addrPort, err := netip.ParseAddrPort(srv.Addr)
if err != nil {
// Technically shouldn't happen, since all servers must have a valid
// address.
panic(fmt.Errorf("websvc: server %q: bad address: %w", srv.Addr, err))
}
// srv.Serve will set TLSConfig to an almost empty value, so, instead of
// relying only on the nilness of TLSConfig, check the length of the
// certificates field as well.
if srv.TLSConfig == nil || len(srv.TLSConfig.Certificates) == 0 {
addrs = append(addrs, addrPort)
} else {
secureAddrs = append(secureAddrs, addrPort)
}
} }
return addrs return addrs, secureAddrs
} }
// handleGetHealthCheck is the handler for the GET /health-check HTTP API. // handleGetHealthCheck is the handler for the GET /health-check HTTP API.
@@ -149,9 +215,6 @@ func (svc *Service) handleGetHealthCheck(w http.ResponseWriter, _ *http.Request)
_, _ = io.WriteString(w, "OK") _, _ = io.WriteString(w, "OK")
} }
// unit is a convenient alias for struct{}.
type unit = struct{}
// type check // type check
var _ agh.Service = (*Service)(nil) var _ agh.Service = (*Service)(nil)
@@ -163,11 +226,9 @@ func (svc *Service) Start() (err error) {
return nil return nil
} }
srvs := svc.servers
wg := &sync.WaitGroup{} wg := &sync.WaitGroup{}
wg.Add(len(srvs)) wg.Add(len(svc.servers))
for _, srv := range srvs { for _, srv := range svc.servers {
go serve(srv, wg) go serve(srv, wg)
} }
@@ -181,11 +242,14 @@ func serve(srv *http.Server, wg *sync.WaitGroup) {
addr := srv.Addr addr := srv.Addr
defer log.OnPanic(addr) defer log.OnPanic(addr)
var proto string
var l net.Listener var l net.Listener
var err error var err error
if srv.TLSConfig == nil { if srv.TLSConfig == nil {
proto = "http"
l, err = net.Listen("tcp", addr) l, err = net.Listen("tcp", addr)
} else { } else {
proto = "https"
l, err = tls.Listen("tcp", addr, srv.TLSConfig) l, err = tls.Listen("tcp", addr, srv.TLSConfig)
} }
if err != nil { if err != nil {
@@ -196,8 +260,12 @@ func serve(srv *http.Server, wg *sync.WaitGroup) {
// would mean that a random available port was automatically chosen. // would mean that a random available port was automatically chosen.
srv.Addr = l.Addr().String() srv.Addr = l.Addr().String()
log.Info("websvc: starting srv http://%s", srv.Addr) log.Info("websvc: starting srv %s://%s", proto, srv.Addr)
wg.Done()
l = &waitListener{
Listener: l,
firstAcceptWG: wg,
}
err = srv.Serve(l) err = srv.Serve(l)
if err != nil && !errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -221,8 +289,28 @@ func (svc *Service) Shutdown(ctx context.Context) (err error) {
} }
if len(errs) > 0 { if len(errs) > 0 {
return errors.List("shutting down") return errors.List("shutting down", errs...)
} }
return nil return nil
} }
// Config returns the current configuration of the web service. Config must not
// be called simultaneously with Start. If svc was initialized with ":0"
// addresses, addrs will not return the actual bound ports until Start is
// finished.
func (svc *Service) Config() (c *Config) {
c = &Config{
ConfigManager: svc.confMgr,
TLS: svc.tls,
// Leave Addresses and SecureAddresses empty and get the actual
// addresses that include the :0 ones later.
Start: svc.start,
Timeout: svc.timeout,
ForceHTTPS: svc.forceHTTPS,
}
c.Addresses, c.SecureAddresses = svc.addrs()
return c
}

Some files were not shown because too many files have changed in this diff Show More