diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index dbe2bad3..bc2caa31 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,7 +1,7 @@
'name': 'build'
'env':
- 'GO_VERSION': '1.18.6'
+ 'GO_VERSION': '1.18.7'
'NODE_VERSION': '14'
'on':
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 64719a3e..1028b6b1 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,7 +1,7 @@
'name': 'lint'
'env':
- 'GO_VERSION': '1.18.6'
+ 'GO_VERSION': '1.18.7'
'on':
'push':
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51adea62..bbeec92c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,30 +11,80 @@ and this project adheres to
## [Unreleased]
-
-### Added
-
-- The new optional `tls.override_tls_ciphers` property list, which can be set in
- the configuration file. It allows overriding TLS Ciphers that are used for
- https listeners ([#4925])
-
-[#4925]: https://github.com/AdguardTeam/AdGuardHome/issues/4925
-
+## Added
+
+- The new optional `tls.override_tls_ciphers` property, which allows
+ overriding TLS ciphers used by AdGuard Home ([#4925], [#4990]).
+- The ability to serve DNS on link-local IPv6 addresses ([#2926]).
+- The ability to put [ClientIDs][clientid] into DNS-over-HTTPS hostnames as
+ opposed to URL paths ([#3418]). Note that AdGuard Home checks the server name
+ only if the URL does not contain a ClientID.
+
+### Changed
+
+- Responses with `SERVFAIL` code are now cached for at least 30 seconds.
+
+### Fixed
+
+- The default value of `dns.cache_size` accidentally set to 0 has now been
+ reverted to 4 MiB ([#5010]).
+- Responses for which the DNSSEC validation had explicitly been omitted aren't
+ cached now ([#4942]).
+- Web UI not switching to HTTP/3 ([#4986], [#4993]).
+
+[#2926]: https://github.com/AdguardTeam/AdGuardHome/issues/2926
+[#3418]: https://github.com/AdguardTeam/AdGuardHome/issues/3418
+[#4925]: https://github.com/AdguardTeam/AdGuardHome/issues/4925
+[#4942]: https://github.com/AdguardTeam/AdGuardHome/issues/4942
+[#4986]: https://github.com/AdguardTeam/AdGuardHome/issues/4986
+[#4990]: https://github.com/AdguardTeam/AdGuardHome/issues/4990
+[#4993]: https://github.com/AdguardTeam/AdGuardHome/issues/4993
+[#5010]: https://github.com/AdguardTeam/AdGuardHome/issues/5010
+
+[clientid]: https://github.com/AdguardTeam/AdGuardHome/wiki/Clients#clientid
+
+<<<<<<< HEAD
+||||||| bf792b83f
+
+=======
+
+## [v0.107.16] - 2022-10-07
+
+This is a security update. There is no GitHub milestone, since no GitHub issues
+were resolved.
+
+## Security
+
+- Go version has been updated to prevent the possibility of exploiting the
+ CVE-2022-2879, CVE-2022-2880, and CVE-2022-41715 Go vulnerabilities fixed in
+ [Go 1.18.7][go-1.18.7].
+
+[go-1.18.7]: https://groups.google.com/g/golang-announce/c/xtuG5faxtaU
+
+
+
+>>>>>>> master
## [v0.107.15] - 2022-10-03
See also the [v0.107.15 GitHub milestone][ms-v0.107.15].
@@ -57,7 +107,7 @@ experimental and may break or change in the future.
explicitly enabled by setting the new property `dns.serve_http3` in the
configuration file to `true`.
- DNS-over-HTTP upstreams can now upgrade to HTTP/3 if the new configuration
- file property `use_http3_upstreams` is set to `true`.
+ file property `dns.use_http3_upstreams` is set to `true`.
- Upstreams with forced DNS-over-HTTP/3 and no fallback to prior HTTP versions
using the `h3://` scheme.
@@ -171,7 +221,7 @@ See also the [v0.107.12 GitHub milestone][ms-v0.107.12].
### Security
-- Go version was updated to prevent the possibility of exploiting the
+- Go version has been updated to prevent the possibility of exploiting the
CVE-2022-27664 and CVE-2022-32190 Go vulnerabilities fixed in
[Go 1.18.6][go-1.18.6].
@@ -292,7 +342,7 @@ See also the [v0.107.9 GitHub milestone][ms-v0.107.9].
### Security
-- Go version was updated to prevent the possibility of exploiting the
+- Go version has been updated to prevent the possibility of exploiting the
CVE-2022-32189 Go vulnerability fixed in [Go 1.18.5][go-1.18.5]. Go 1.17
support has also been removed, as it has reached end of life and will not
receive security updates.
@@ -335,7 +385,7 @@ See also the [v0.107.8 GitHub milestone][ms-v0.107.8].
### Security
-- Go version was updated to prevent the possibility of exploiting the
+- Go version has been updated to prevent the possibility of exploiting the
CVE-2022-1705, CVE-2022-32148, CVE-2022-30631, and other Go vulnerabilities
fixed in [Go 1.17.12][go-1.17.12].
@@ -371,7 +421,7 @@ See also the [v0.107.7 GitHub milestone][ms-v0.107.7].
### Security
-- Go version was updated to prevent the possibility of exploiting the
+- Go version has been updated to prevent the possibility of exploiting the
[CVE-2022-29526], [CVE-2022-30634], [CVE-2022-30629], [CVE-2022-30580], and
[CVE-2022-29804] Go vulnerabilities.
- Enforced password strength policy ([#3503]).
@@ -528,7 +578,7 @@ See also the [v0.107.6 GitHub milestone][ms-v0.107.6].
### Security
- `User-Agent` HTTP header removed from outgoing DNS-over-HTTPS requests.
-- Go version was updated to prevent the possibility of exploiting the
+- Go version has been updated to prevent the possibility of exploiting the
[CVE-2022-24675], [CVE-2022-27536], and [CVE-2022-28327] Go vulnerabilities.
### Added
@@ -583,7 +633,7 @@ were resolved.
### Security
-- Go version was updated to prevent the possibility of exploiting the
+- Go version has been updated to prevent the possibility of exploiting the
[CVE-2022-24921] Go vulnerability.
[CVE-2022-24921]: https://www.cvedetails.com/cve/CVE-2022-24921
@@ -596,7 +646,7 @@ See also the [v0.107.4 GitHub milestone][ms-v0.107.4].
### Security
-- Go version was updated to prevent the possibility of exploiting the
+- Go version has been updated to prevent the possibility of exploiting the
[CVE-2022-23806], [CVE-2022-23772], and [CVE-2022-23773] Go vulnerabilities.
### Fixed
@@ -1333,11 +1383,12 @@ See also the [v0.104.2 GitHub milestone][ms-v0.104.2].
-[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.15...HEAD
+[Unreleased]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.16...HEAD
+[v0.107.16]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.15...v0.107.16
[v0.107.15]: https://github.com/AdguardTeam/AdGuardHome/compare/v0.107.14...v0.107.15
[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
diff --git a/Makefile b/Makefile
index b4823bb7..cca89017 100644
--- a/Makefile
+++ b/Makefile
@@ -34,7 +34,7 @@ YARN_INSTALL_FLAGS = $(YARN_FLAGS) --network-timeout 120000 --silent\
--ignore-engines --ignore-optional --ignore-platform\
--ignore-scripts
-V1API = 0
+NEXTAPI = 0
# Macros for the build-release target. If FRONTEND_PREBUILT is 0, the
# default, the macro $(BUILD_RELEASE_DEPS_$(FRONTEND_PREBUILT)) expands
@@ -63,7 +63,7 @@ ENV = env\
PATH="$${PWD}/bin:$$( "$(GO.MACRO)" env GOPATH )/bin:$${PATH}"\
RACE='$(RACE)'\
SIGN='$(SIGN)'\
- V1API='$(V1API)'\
+ NEXTAPI='$(NEXTAPI)'\
VERBOSE='$(VERBOSE)'\
VERSION='$(VERSION)'\
diff --git a/README.md b/README.md
index 43c9db89..1f2bc7a3 100644
--- a/README.md
+++ b/README.md
@@ -10,68 +10,76 @@
Free and open source, powerful network-wide ads & trackers blocking DNS
server.
-
AdGuard.com |
Wiki |
Reddit |
Twitter |
Telegram
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
-
-
+
-
+
+
-
-
-AdGuard Home is a network-wide software for blocking ads & tracking. After you set it up, it'll cover ALL your home devices, and you don't need any client-side software for that.
+AdGuard Home is a network-wide software for blocking ads and tracking. After you
+set it up, it'll cover ALL your home devices, and you don't need any client-side
+software for that.
It operates as a DNS server that re-routes tracking domains to a “black hole”,
thus preventing your devices from connecting to those servers. It's based on
-software we use for our public [AdGuard DNS](https://adguard-dns.io/) servers,
-and both share a lot of code.
+software we use for our public [AdGuard DNS] servers, and both share a lot of
+code.
+
+[AdGuard DNS]: https://adguard-dns.io/
-* [Getting Started](#getting-started)
-* [Comparing AdGuard Home to other solutions](#comparison)
- * [How is this different from public AdGuard DNS servers?](#comparison-adguard-dns)
- * [How does AdGuard Home compare to Pi-Hole](#comparison-pi-hole)
- * [How does AdGuard Home compare to traditional ad blockers](#comparison-adblock)
-* [How to build from source](#how-to-build)
-* [Contributing](#contributing)
- * [Test unstable versions](#test-unstable-versions)
- * [Reporting issues](#reporting-issues)
- * [Help with translations](#translate)
- * [Other](#help-other)
-* [Projects that use AdGuard Home](#uses)
-* [Acknowledgments](#acknowledgments)
-* [Privacy](#privacy)
+ * [Getting Started](#getting-started)
+ * [Automated install (Unix)](#automated-install-linux-and-mac)
+ * [Alternative methods](#alternative-methods)
+ * [Guides](#guides)
+ * [API](#api)
+ * [Comparing AdGuard Home to other solutions](#comparison)
+ * [How is this different from public AdGuard DNS servers?](#comparison-adguard-dns)
+ * [How does AdGuard Home compare to Pi-Hole](#comparison-pi-hole)
+ * [How does AdGuard Home compare to traditional ad blockers](#comparison-adblock)
+ * [Known limitations](#comparison-limitations)
+ * [How to build from source](#how-to-build)
+ * [Prerequisites](#prerequisites)
+ * [Building](#building)
+ * [Contributing](#contributing)
+ * [Test unstable versions](#test-unstable-versions)
+ * [Reporting issues](#reporting-issues)
+ * [Help with translations](#translate)
+ * [Other](#help-other)
+ * [Projects that use AdGuard Home](#uses)
+ * [Acknowledgments](#acknowledgments)
+ * [Privacy](#privacy)
-
-## Getting Started
-### Automated install (Linux and Mac)
+
+## Getting Started
+
+ ### Automated install (Unix)
Run the following command in your terminal:
@@ -80,73 +88,96 @@ curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/s
```
The script also accepts some options:
-* `-c ` to use specified channel.
-* `-r` to reinstall AdGuard Home;
-* `-u` to uninstall AdGuard Home;
-* `-v` for verbose output;
+
+ * `-c ` to use specified channel;
+ * `-r` to reinstall AdGuard Home;
+ * `-u` to uninstall AdGuard Home;
+ * `-v` for verbose output.
Note that options `-r` and `-u` are mutually exclusive.
-### Alternative methods
-#### Manual installation
-Please read the **[Getting Started](https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started)** article on our Wiki to learn how to install AdGuard Home manually, and how to configure your devices to use it.
+ ### Alternative methods
-#### Docker
+ #### Manual installation
-You can use our [official Docker image](https://hub.docker.com/r/adguard/adguardhome).
+Please read the **[Getting Started][wiki-start]** article on our Wiki to learn
+how to install AdGuard Home manually, and how to configure your devices to use
+it.
-#### Snap Store
+ #### Docker
-If you're running **Linux**, there's a secure and easy way to install AdGuard Home - you can get it from the [Snap Store](https://snapcraft.io/adguard-home).
+You can use our official Docker image on [Docker Hub].
-### Guides
+ #### Snap Store
-* [Getting Started](https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started)
- * [FAQ](https://github.com/AdguardTeam/AdGuardHome/wiki/FAQ)
- * [How to Write Hosts Blocklists](https://github.com/AdguardTeam/AdGuardHome/wiki/Hosts-Blocklists)
- * [Comparing AdGuard Home to Other Solutions](https://github.com/AdguardTeam/AdGuardHome/wiki/Comparison)
-* Configuring AdGuard
- * [Configuration](https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration)
- * [Configuring AdGuard Home Clients](https://github.com/AdguardTeam/AdGuardHome/wiki/Clients)
- * [AdGuard Home as a DoH, DoT, or DoQ Server](https://github.com/AdguardTeam/AdGuardHome/wiki/Encryption)
- * [AdGuard Home as a DNSCrypt Server](https://github.com/AdguardTeam/AdGuardHome/wiki/DNSCrypt)
- * [AdGuard Home as a DHCP Server](https://github.com/AdguardTeam/AdGuardHome/wiki/DHCP)
-* Installing AdGuard Home
- * [Docker](https://github.com/AdguardTeam/AdGuardHome/wiki/Docker)
- * [How to Install and Run AdGuard Home on a Raspberry Pi](https://github.com/AdguardTeam/AdGuardHome/wiki/Raspberry-Pi)
- * [How to Install and Run AdGuard Home on a Virtual Private Server](https://github.com/AdguardTeam/AdGuardHome/wiki/VPS)
-* [Verifying Releases](https://github.com/AdguardTeam/AdGuardHome/wiki/Verify-Releases)
+If you're running **Linux,** there's a secure and easy way to install AdGuard
+Home: get it from the [Snap Store].
-### API
+[Docker Hub]: https://hub.docker.com/r/adguard/adguardhome
+[Snap Store]: https://snapcraft.io/adguard-home
+[wiki-start]: https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started
-If you want to integrate with AdGuard Home, you can use our [REST API](https://github.com/AdguardTeam/AdGuardHome/tree/master/openapi).
-Alternatively, you can use this [python client](https://pypi.org/project/adguardhome/), which is used to build the [AdGuard Home Hass.io Add-on](https://www.home-assistant.io/integrations/adguard/).
-
-## Comparing AdGuard Home to other solutions
-
-### How is this different from public AdGuard DNS servers?
+ ### Guides
-Running your own AdGuard Home server allows you to do much more than using a public DNS server. It's a completely different level. See for yourself:
+See our [Wiki][wiki].
-* Choose what exactly the server blocks and permits.
-* Monitor your network activity.
-* Add your own custom filtering rules.
-* **Most importantly, this is your own server, and you are the only one who's in control.**
+[wiki]: https://github.com/AdguardTeam/AdGuardHome/wiki
-
-### How does AdGuard Home compare to Pi-Hole
-At this point, AdGuard Home has a lot in common with Pi-Hole. Both block ads and trackers using "DNS sinkholing" method, and both allow customizing what's blocked.
-> We're not going to stop here. DNS sinkholing is not a bad starting point, but this is just the beginning.
+ ### API
-AdGuard Home provides a lot of features out-of-the-box with no need to install and configure additional software. We want it to be simple to the point when even casual users can set it up with minimal effort.
+If you want to integrate with AdGuard Home, you can use our [REST API][openapi].
+Alternatively, you can use this [python client][pyclient], which is used to
+build the [AdGuard Home Hass.io Add-on][hassio].
-> Disclaimer: some of the listed features can be added to Pi-Hole by installing additional software or by manually using SSH terminal and reconfiguring one of the utilities Pi-Hole consists of. However, in our opinion, this cannot be legitimately counted as a Pi-Hole's feature.
+[hassio]: https://www.home-assistant.io/integrations/adguard/
+[openapi]: https://github.com/AdguardTeam/AdGuardHome/tree/master/openapi
+[pyclient]: https://pypi.org/project/adguardhome/
+
+
+
+## Comparing AdGuard Home to other solutions
+
+ ### How is this different from public AdGuard DNS servers?
+
+Running your own AdGuard Home server allows you to do much more than using a
+public DNS server. It's a completely different level. See for yourself:
+
+ * Choose what exactly the server blocks and permits.
+
+ * Monitor your network activity.
+
+ * Add your own custom filtering rules.
+
+ * **Most importantly, it's your own server, and you are the only one who's in
+ control.**
+
+
+
+ ### How does AdGuard Home compare to Pi-Hole
+
+At this point, AdGuard Home has a lot in common with Pi-Hole. Both block ads
+and trackers using the so-called “DNS sinkholing” method and both allow
+customizing what's blocked.
+
+
+We're not going to stop here. DNS sinkholing is not a bad starting point, but
+this is just the beginning.
+
+
+AdGuard Home provides a lot of features out-of-the-box with no need to install
+and configure additional software. We want it to be simple to the point when
+even casual users can set it up with minimal effort.
+
+**Disclaimer:** some of the listed features can be added to Pi-Hole by
+installing additional software or by manually using SSH terminal and
+reconfiguring one of the utilities Pi-Hole consists of. However, in our
+opinion, this cannot be legitimately counted as a Pi-Hole's feature.
| Feature | AdGuard Home | Pi-Hole |
|-------------------------------------------------------------------------|-------------------|-----------------------------------------------------------|
@@ -162,53 +193,72 @@ AdGuard Home provides a lot of features out-of-the-box with no need to install a
| Force Safe search on search engines | ✅ | ❌ |
| Per-client (device) configuration | ✅ | ✅ |
| Access settings (choose who can use AGH DNS) | ✅ | ❌ |
-| Running [without root privileges](https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#running-without-superuser) | ✅ | ❌ |
+| Running [without root privileges][wiki-noroot] | ✅ | ❌ |
-
-### How does AdGuard Home compare to traditional ad blockers
+[wiki-noroot]: https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#running-without-superuser
+
+
+
+ ### How does AdGuard Home compare to traditional ad blockers
It depends.
-“DNS sinkholing” is capable of blocking a big percentage of ads, but it lacks
-flexibility and power of traditional ad blockers. You can get a good impression
-about the difference between these methods by reading
-[this article](https://adguard.com/en/blog/adguard-vs-adaway-dns66/). It
-compares AdGuard for Android (a traditional ad blocker) to hosts-level ad
-blockers (which are almost identical to DNS-based blockers in their
-capabilities). This level of protection is enough for some users.
+DNS sinkholing is capable of blocking a big percentage of ads, but it lacks
+the flexibility and the power of traditional ad blockers. You can get a good
+impression about the difference between these methods by reading [this
+article][blog-adaway], which compares AdGuard for Android (a traditional ad
+blocker) to hosts-level ad blockers (which are almost identical to DNS-based
+blockers in their capabilities). This level of protection is enough for some
+users.
+
+Additionally, using a DNS-based blocker can help to block ads, tracking and
+analytics requests on other types of devices, such as SmartTVs, smart speakers
+or other kinds of IoT devices (on which you can't install traditional ad
+blockers).
-Additionally, using a DNS-based blocker can help to block ads, tracking and analytics requests on other types of devices, such as SmartTVs, smart speakers or other kinds of IoT devices (on which you can't install traditional ad blockers).
-
-**Known limitations**
+ ### Known limitations
Here are some examples of what cannot be blocked by a DNS-level blocker:
-* YouTube, Twitch ads
-* Facebook, Twitter, Instagram sponsored posts
+ * YouTube, Twitch ads;
-Essentially, any advertising that shares a domain with content cannot be blocked by a DNS-level blocker.
+ * Facebook, Twitter, Instagram sponsored posts.
-Is there a chance to handle this in the future? DNS will never be enough to do this. Our only option is to use a content blocking proxy like what we do in the standalone AdGuard applications. We're [going to bring](https://github.com/AdguardTeam/AdGuardHome/issues/1228) this feature support to AdGuard Home in the future. Unfortunately, even in this case, there still will be cases when this won't be enough or would require quite a complicated configuration.
+Essentially, any advertising that shares a domain with content cannot be blocked
+by a DNS-level blocker.
-
-## How to build from source
+Is there a chance to handle this in the future? DNS will never be enough to do
+this. Our only option is to use a content blocking proxy like what we do in the
+standalone AdGuard applications. We're [going to bring][issue-1228] this
+feature support to AdGuard Home in the future. Unfortunately, even in this
+case, there still will be cases when this won't be enough or would require quite
+a complicated configuration.
-### Prerequisites
+[blog-adaway]: https://adguard.com/blog/adguard-vs-adaway-dns66.html
+[issue-1228]: https://github.com/AdguardTeam/AdGuardHome/issues/1228
+
+
+
+## How to build from source
+
+ ### Prerequisites
Run `make init` to prepare the development environment.
You will need this to build AdGuard Home:
- * [go](https://golang.org/dl/) v1.18 or later.
- * [node.js](https://nodejs.org/en/download/) v10.16.2 or later.
- * [npm](https://www.npmjs.com/) v6.14 or later (temporary requirement, TODO: remove when redesign is finished).
- * [yarn](https://yarnpkg.com/) v1.22.5 or later.
+ * [Go](https://golang.org/dl/) v1.18 or later;
+ * [Node.js](https://nodejs.org/en/download/) v10.16.2 or later;
+ * [npm](https://www.npmjs.com/) v6.14 or later;
+ * [yarn](https://yarnpkg.com/) v1.22.5 or later.
-### Building
-Open Terminal and execute these commands:
+
+ ### Building
+
+Open your terminal and execute these commands:
```sh
git clone https://github.com/AdguardTeam/AdGuardHome
@@ -216,16 +266,18 @@ cd AdGuardHome
make
```
-Please note, that the non-standard `-j` flag is currently not supported, so
-building with `make -j 4` or setting your `MAKEFLAGS` to include, for example,
-`-j 4` is likely to break the build. If you do have your `MAKEFLAGS` set to
-that, and you don't want to change it, you can override it by running
-`make -j 1`.
+**NOTE:** The non-standard `-j` flag is currently not supported, so building
+with `make -j 4` or setting your `MAKEFLAGS` to include, for example, `-j 4` is
+likely to break the build. If you do have your `MAKEFLAGS` set to that, and you
+don't want to change it, you can override it by running `make -j 1`.
-Check the [`Makefile`](https://github.com/AdguardTeam/AdGuardHome/blob/master/Makefile) to learn about other commands.
+Check the [`Makefile`][src-makefile] to learn about other commands.
-**Building for a different platform.** You can build AdGuard for any OS/ARCH just like any other Go project.
-In order to do this, specify `GOOS` and `GOARCH` env variables before running make.
+ #### Building for a different platform
+
+You can build AdGuard Home for any OS/ARCH that Go supports. In order to do
+this, specify `GOOS` and `GOARCH` environment variables as macros when running
+`make`.
For example:
@@ -239,168 +291,223 @@ or:
make GOOS='linux' GOARCH='arm64'
```
-#### Preparing release
+ #### Preparing releases
-You'll need this to prepare a release build:
-
-* [snapcraft](https://snapcraft.io/)
-
-Commands:
+You'll need [`snapcraft`] to prepare a release build. Once installed, run the
+following command:
```sh
make build-release CHANNEL='...' VERSION='...'
```
-#### Docker image
+See the [`build-release` target documentation][targ-release].
-* Run `make build-docker` to build the Docker image locally (the one that we publish to DockerHub).
+ #### Docker image
-Please note, that we're using [Docker Buildx](https://docs.docker.com/buildx/working-with-buildx/) to build our official image.
+Run `make build-docker` to build the Docker image locally (the one that we
+publish to DockerHub). Please note, that we're using [Docker Buildx][buildx] to
+build our official image.
You may need to prepare before using these builds:
-* (Linux-only) Install Qemu: `docker run --rm --privileged multiarch/qemu-user-static --reset -p yes --credential yes`
-* Prepare builder: `docker buildx create --name buildx-builder --driver docker-container --use`
+ * (Linux-only) Install Qemu:
+
+ ```sh
+ docker run --rm --privileged multiarch/qemu-user-static --reset -p yes --credential yes
+ ```
+
+ * Prepare the builder:
+
+ ```sh
+ docker buildx create --name buildx-builder --driver docker-container --use
+ ```
+
+See the [`build-docker` target documentation][targ-docker].
+
+ #### Debugging the frontend
+
+When you need to debug the frontend without recompiling the production version
+every time, for example to check how your labels would look on a form, you can
+run the frontend build a development environment.
+
+1. In a separate terminal, run:
+
+ ```sh
+ ( cd ./client/ && env NODE_ENV='development' npm run watch )
+ ```
+
+2. Run your `AdGuardHome` binary with the `--local-frontend` flag, which
+ instructs AdGuard Home to ignore the built-in frontend files and use those
+ from the `./build/` directory.
+
+3. Now any changes you make in the `./client/` directory should be recompiled
+ and become available on the web UI. Make sure that you disable the browser
+ cache to make sure that you actually get the recompiled version.
+
+[`snapcraft`]: https://snapcraft.io/
+[buildx]: https://docs.docker.com/buildx/working-with-buildx/
+[src-makefile]: https://github.com/AdguardTeam/AdGuardHome/blob/master/Makefile
+[targ-docker]: https://github.com/AdguardTeam/AdGuardHome/tree/master/scripts#build-dockersh-build-a-multi-architecture-docker-image
+[targ-release]: https://github.com/AdguardTeam/AdGuardHome/tree/master/scripts#build-releasesh-build-a-release-for-all-platforms
-### Resources that we update periodically
-* `scripts/translations`
-* `scripts/whotracksme`
+## Contributing
-
-## Contributing
+You are welcome to fork this repository, make your changes and [submit a pull
+request][pr]. Please make sure you follow our [code guidelines][guide] though.
-You are welcome to fork this repository, make your changes and submit a pull request — https://github.com/AdguardTeam/AdGuardHome/pulls
+Please note that we don't expect people to contribute to both UI and backend
+parts of the program simultaneously. Ideally, the backend part is implemented
+first, i.e. configuration, API, and the functionality itself. The UI part can
+be implemented later in a different pull request by a different person.
-Please note that we don't expect people to contribute to both UI and golang parts of the program simultaneously. Ideally, the golang part is implemented first, i.e. configuration, API, and the functionality itself. The UI part can be implemented later in a different pull request by a different person.
+[guide]: https://github.com/AdguardTeam/CodeGuidelines/
+[pr]: https://github.com/AdguardTeam/AdGuardHome/pulls
-
-### Test unstable versions
+
+
+ ### Test unstable versions
There are two update channels that you can use:
-* `beta` - beta version of AdGuard Home. More or less stable versions.
-* `edge` - the newest version of AdGuard Home. New updates are pushed to this channel daily and it is the closest to the master branch you can get.
+ * `beta`: beta versions of AdGuard Home. More or less stable versions,
+ usually released every two weeks or more often.
+
+ * `edge`: the newest version of AdGuard Home from the development branch. New
+ updates are pushed to this channel daily.
There are three options how you can install an unstable version:
-1. [Snap Store](https://snapcraft.io/adguard-home) -- look for "beta" and "edge" channels there.
-2. [Docker Hub](https://hub.docker.com/r/adguard/adguardhome) -- look for "beta" and "edge" tags there.
-3. Standalone builds. Use the automated installation script or look for the available builds below.
+1. [Snap Store]: look for the `beta` and `edge` channels.
-Beta:
+2. [Docker Hub]: look for the `beta` and `edge` tags.
-```sh
-curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -c beta
-```
+3. Standalone builds. Use the automated installation script or look for the
+ available builds [on the Wiki][wiki-platf].
-Edge:
+ Script to install a beta version:
-```sh
-curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -c edge
-```
+ ```sh
+ curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -c beta
+ ```
- * Beta channel builds
- * Linux: [64-bit](https://static.adtidy.org/adguardhome/beta/AdGuardHome_linux_amd64.tar.gz), [32-bit](https://static.adtidy.org/adguardhome/beta/AdGuardHome_linux_386.tar.gz)
- * Linux ARM: [32-bit ARMv6](https://static.adtidy.org/adguardhome/beta/AdGuardHome_linux_armv6.tar.gz) (recommended for Raspberry Pi OS stable), [64-bit](https://static.adtidy.org/adguardhome/beta/AdGuardHome_linux_arm64.tar.gz), [32-bit ARMv5](https://static.adtidy.org/adguardhome/beta/AdGuardHome_linux_armv5.tar.gz), [32-bit ARMv7](https://static.adtidy.org/adguardhome/beta/AdGuardHome_linux_armv7.tar.gz)
- * Linux MIPS: [32-bit MIPS](https://static.adtidy.org/adguardhome/beta/AdGuardHome_linux_mips_softfloat.tar.gz), [32-bit MIPSLE](https://static.adtidy.org/adguardhome/beta/AdGuardHome_linux_mipsle_softfloat.tar.gz), [64-bit MIPS](https://static.adtidy.org/adguardhome/beta/AdGuardHome_linux_mips64_softfloat.tar.gz), [64-bit MIPSLE](https://static.adtidy.org/adguardhome/beta/AdGuardHome_linux_mips64le_softfloat.tar.gz)
- * Windows: [64-bit](https://static.adtidy.org/adguardhome/beta/AdGuardHome_windows_amd64.zip), [32-bit](https://static.adtidy.org/adguardhome/beta/AdGuardHome_windows_386.zip)
- * macOS: [64-bit](https://static.adtidy.org/adguardhome/beta/AdGuardHome_darwin_amd64.zip), [32-bit](https://static.adtidy.org/adguardhome/beta/AdGuardHome_darwin_386.zip)
- * macOS ARM: [64-bit](https://static.adtidy.org/adguardhome/beta/AdGuardHome_darwin_arm64.zip)
- * FreeBSD: [64-bit](https://static.adtidy.org/adguardhome/beta/AdGuardHome_freebsd_amd64.tar.gz), [32-bit](https://static.adtidy.org/adguardhome/beta/AdGuardHome_freebsd_386.tar.gz)
- * FreeBSD ARM: [64-bit](https://static.adtidy.org/adguardhome/beta/AdGuardHome_freebsd_arm64.tar.gz), [32-bit ARMv5](https://static.adtidy.org/adguardhome/beta/AdGuardHome_freebsd_armv5.tar.gz), [32-bit ARMv6](https://static.adtidy.org/adguardhome/beta/AdGuardHome_freebsd_armv6.tar.gz), [32-bit ARMv7](https://static.adtidy.org/adguardhome/beta/AdGuardHome_freebsd_armv7.tar.gz)
- * OpenBSD: (coming soon)
- * OpenBSD ARM: (coming soon)
+ Script to install an edge version:
- * Edge channel builds
- * Linux: [64-bit](https://static.adtidy.org/adguardhome/edge/AdGuardHome_linux_amd64.tar.gz), [32-bit](https://static.adtidy.org/adguardhome/edge/AdGuardHome_linux_386.tar.gz)
- * Linux ARM: [32-bit ARMv6](https://static.adtidy.org/adguardhome/edge/AdGuardHome_linux_armv6.tar.gz) (recommended for Raspberry Pi OS stable), [64-bit](https://static.adtidy.org/adguardhome/edge/AdGuardHome_linux_arm64.tar.gz), [32-bit ARMv5](https://static.adtidy.org/adguardhome/edge/AdGuardHome_linux_armv5.tar.gz), [32-bit ARMv7](https://static.adtidy.org/adguardhome/edge/AdGuardHome_linux_armv7.tar.gz)
- * Linux MIPS: [32-bit MIPS](https://static.adtidy.org/adguardhome/edge/AdGuardHome_linux_mips_softfloat.tar.gz), [32-bit MIPSLE](https://static.adtidy.org/adguardhome/edge/AdGuardHome_linux_mipsle_softfloat.tar.gz), [64-bit MIPS](https://static.adtidy.org/adguardhome/edge/AdGuardHome_linux_mips64_softfloat.tar.gz), [64-bit MIPSLE](https://static.adtidy.org/adguardhome/edge/AdGuardHome_linux_mips64le_softfloat.tar.gz)
- * Windows: [64-bit](https://static.adtidy.org/adguardhome/edge/AdGuardHome_windows_amd64.zip), [32-bit](https://static.adtidy.org/adguardhome/edge/AdGuardHome_windows_386.zip)
- * macOS: [64-bit](https://static.adtidy.org/adguardhome/edge/AdGuardHome_darwin_amd64.zip), [32-bit](https://static.adtidy.org/adguardhome/edge/AdGuardHome_darwin_386.zip)
- * macOS ARM: [64-bit](https://static.adtidy.org/adguardhome/edge/AdGuardHome_darwin_arm64.zip)
- * FreeBSD: [64-bit](https://static.adtidy.org/adguardhome/edge/AdGuardHome_freebsd_amd64.tar.gz), [32-bit](https://static.adtidy.org/adguardhome/edge/AdGuardHome_freebsd_386.tar.gz)
- * FreeBSD ARM: [64-bit](https://static.adtidy.org/adguardhome/edge/AdGuardHome_freebsd_arm64.tar.gz), [32-bit ARMv5](https://static.adtidy.org/adguardhome/edge/AdGuardHome_freebsd_armv5.tar.gz), [32-bit ARMv6](https://static.adtidy.org/adguardhome/edge/AdGuardHome_freebsd_armv6.tar.gz), [32-bit ARMv7](https://static.adtidy.org/adguardhome/edge/AdGuardHome_freebsd_armv7.tar.gz)
- * OpenBSD: [64-bit (experimental)](https://static.adtidy.org/adguardhome/edge/AdGuardHome_openbsd_amd64.tar.gz)
- * OpenBSD ARM: [64-bit (experimental)](https://static.adtidy.org/adguardhome/edge/AdGuardHome_openbsd_arm64.tar.gz)
+ ```sh
+ curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -c edge
+ ```
+[wiki-platf]: https://github.com/AdguardTeam/AdGuardHome/wiki/Platforms
-
-### Report issues
-If you run into any problem or have a suggestion, head to [this page](https://github.com/AdguardTeam/AdGuardHome/issues) and click on the `New issue` button.
+ ### Report issues
-
-### Help with translations
+If you run into any problem or have a suggestion, head to [this page][iss] and
+click on the “New issue” button.
+
+[iss]: https://github.com/AdguardTeam/AdGuardHome/issues
+
+
+
+ ### Help with translations
If you want to help with AdGuard Home translations, please learn more about
-translating AdGuard products
-[in our Knowledge Base](https://kb.adguard.com/en/general/adguard-translations).
+translating AdGuard products [in our Knowledge Base][kb-trans]. You can
+contribute to the [AdGuardHome project on CrowdIn][crowdin].
-Here is a link to AdGuard Home project:
-
+[crowdin]: https://crowdin.com/project/adguard-applications/en#/adguard-home
+[kb-trans]: https://kb.adguard.com/en/general/adguard-translations
-
-### Other
-Here's what you can also do to contribute:
-1. [Look for issues][helpissues] marked as "help wanted".
-2. Actualize the list of *Blocked services*. It can be found in
- [filtering/blocked.go][blocked.go].
-3. Actualize the list of known *trackers*. It it can be found in [this repo]
- [companiesdb].
-4. Actualize the list of vetted *blocklists*. It it can be found in
- [client/src/helpers/filters/filters.json][filters.json].
+ ### Other
-[helpissues]: https://github.com/AdguardTeam/AdGuardHome/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22+
-[blocked.go]: https://github.com/AdguardTeam/AdGuardHome/blob/master/internal/filtering/blocked.go
-[companiesdb]: https://github.com/AdguardTeam/companiesdb
-[filters.json]: https://github.com/AdguardTeam/AdGuardHome/blob/master/client/src/helpers/filters/filters.json
+Another way you can contribute is by [looking for issues][iss-help] marked as
+`help wanted`, asking if the issue is up for grabs, and sending a PR fixing the
+bug or implementing the feature.
-
-## Projects that use AdGuard Home
+[iss-help]: https://github.com/AdguardTeam/AdGuardHome/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22
-* [AdGuard Home Remote](https://apps.apple.com/app/apple-store/id1543143740) - iOS app by [Joost](https://rocketscience-it.nl/)
-* [Python library](https://github.com/frenck/python-adguardhome) by [@frenck](https://github.com/frenck)
-* [Home Assistant add-on](https://github.com/hassio-addons/addon-adguard-home) by [@frenck](https://github.com/frenck)
-* [OpenWrt LUCI app](https://github.com/kongfl888/luci-app-adguardhome) by [@kongfl888](https://github.com/kongfl888) (originally by [@rufengsuixing](https://github.com/rufengsuixing))
-* [Prometheus exporter for AdGuard Home](https://github.com/ebrianne/adguard-exporter) by [@ebrianne](https://github.com/ebrianne)
-* [AdGuard Home on GLInet routers](https://forum.gl-inet.com/t/adguardhome-on-gl-routers/10664) by [Gl-Inet](https://gl-inet.com/)
-* [Cloudron app](https://git.cloudron.io/cloudron/adguard-home-app) by [@gramakri](https://github.com/gramakri)
-* [Asuswrt-Merlin-AdGuardHome-Installer](https://github.com/jumpsmm7/Asuswrt-Merlin-AdGuardHome-Installer) by [@jumpsmm7](https://github.com/jumpsmm7) aka [@SomeWhereOverTheRainBow](https://www.snbforums.com/members/somewhereovertherainbow.64179/)
-* [Node.js library](https://github.com/Andrea055/AdguardHomeAPI) by [@Andrea055](https://github.com/Andrea055/)
-
-## Acknowledgments
+
+## Projects that use AdGuard Home
+
+
+
+ * [AdGuard Home Remote](https://apps.apple.com/app/apple-store/id1543143740):
+ iOS app by [Joost](https://rocketscience-it.nl/).
+
+ * [Python library](https://github.com/frenck/python-adguardhome) by
+ [@frenck](https://github.com/frenck).
+
+ * [Home Assistant add-on](https://github.com/hassio-addons/addon-adguard-home)
+ by [@frenck](https://github.com/frenck).
+
+ * [OpenWrt LUCI app](https://github.com/kongfl888/luci-app-adguardhome) by
+ [@kongfl888](https://github.com/kongfl888) (originally by
+ [@rufengsuixing](https://github.com/rufengsuixing)).
+
+ * [Prometheus exporter for AdGuard
+ Home](https://github.com/ebrianne/adguard-exporter) by
+ [@ebrianne](https://github.com/ebrianne).
+
+ * [AdGuard Home on GLInet
+ routers](https://forum.gl-inet.com/t/adguardhome-on-gl-routers/10664) by
+ [Gl-Inet](https://gl-inet.com/).
+
+ * [Cloudron app](https://git.cloudron.io/cloudron/adguard-home-app) by
+ [@gramakri](https://github.com/gramakri).
+
+ * [Asuswrt-Merlin-AdGuardHome-Installer](https://github.com/jumpsmm7/Asuswrt-Merlin-AdGuardHome-Installer)
+ by [@jumpsmm7](https://github.com/jumpsmm7) aka
+ [@SomeWhereOverTheRainBow](https://www.snbforums.com/members/somewhereovertherainbow.64179/).
+
+ * [Node.js library](https://github.com/Andrea055/AdguardHomeAPI) by
+ [@Andrea055](https://github.com/Andrea055/).
+
+
+
+## Acknowledgments
+
+
This software wouldn't have been possible without:
- * [Go](https://golang.org/dl/) and its libraries:
- * [gcache](https://github.com/bluele/gcache)
- * [miekg's dns](https://github.com/miekg/dns)
- * [go-yaml](https://github.com/go-yaml/yaml)
- * [service](https://godoc.org/github.com/kardianos/service)
- * [dnsproxy](https://github.com/AdguardTeam/dnsproxy)
- * [urlfilter](https://github.com/AdguardTeam/urlfilter)
- * [Node.js](https://nodejs.org/) and its libraries:
- * [React.js](https://reactjs.org)
- * [Tabler](https://github.com/tabler/tabler)
- * And many more node.js packages.
- * [whotracks.me data](https://github.com/cliqz-oss/whotracks.me)
+ * [Go](https://golang.org/dl/) and its libraries:
+ * [gcache](https://github.com/bluele/gcache)
+ * [miekg's dns](https://github.com/miekg/dns)
+ * [go-yaml](https://github.com/go-yaml/yaml)
+ * [service](https://godoc.org/github.com/kardianos/service)
+ * [dnsproxy](https://github.com/AdguardTeam/dnsproxy)
+ * [urlfilter](https://github.com/AdguardTeam/urlfilter)
+ * [Node.js](https://nodejs.org/) and its libraries:
+ * And many more Node.js packages.
+ * [React.js](https://reactjs.org)
+ * [Tabler](https://github.com/tabler/tabler)
+ * [whotracks.me data](https://github.com/cliqz-oss/whotracks.me)
-You might have seen that [CoreDNS](https://coredns.io) was mentioned here
-before, but we've stopped using it in AdGuard Home.
+You might have seen that [CoreDNS] was mentioned here before, but we've stopped
+using it in AdGuard Home.
-For a full list of all node.js packages in use, please take a look at [client/package.json](https://github.com/AdguardTeam/AdGuardHome/blob/master/client/package.json) file.
+For the full list of all Node.js packages in use, please take a look at
+[`client/package.json`][src-packagejson] file.
-
-## Privacy
+[CoreDNS]: https://coredns.io
+[src-packagejson]: https://github.com/AdguardTeam/AdGuardHome/blob/master/client/package.json
+
+## Privacy
+
Our main idea is that you are the one, who should be in control of your data.
So it is only natural, that AdGuard Home does not collect any usage statistics,
-and does not use any web services unless you configure it to do so. Full policy
-with every bit that *could in theory be* sent by AdGuard Home is available
-[here](https://adguard.com/en/privacy/home.html)
+and does not use any web services unless you configure it to do so. See also
+the [full privacy policy][privacy] with every bit that *could in theory be sent*
+by AdGuard Home is available.
+
+[privacy]: https://adguard.com/en/privacy/home.html
diff --git a/bamboo-specs/release.yaml b/bamboo-specs/release.yaml
index ddd95734..4232b734 100644
--- a/bamboo-specs/release.yaml
+++ b/bamboo-specs/release.yaml
@@ -7,7 +7,7 @@
# Make sure to sync any changes with the branch overrides below.
'variables':
'channel': 'edge'
- 'dockerGo': 'adguard/golang-ubuntu:5.1'
+ 'dockerGo': 'adguard/golang-ubuntu:5.2'
'stages':
- 'Build frontend':
@@ -322,7 +322,7 @@
# need to build a few of these.
'variables':
'channel': 'beta'
- 'dockerGo': 'adguard/golang-ubuntu:5.1'
+ 'dockerGo': 'adguard/golang-ubuntu:5.2'
# release-vX.Y.Z branches are the branches from which the actual final release
# is built.
- '^release-v[0-9]+\.[0-9]+\.[0-9]+':
@@ -337,4 +337,4 @@
# are the ones that actually get released.
'variables':
'channel': 'release'
- 'dockerGo': 'adguard/golang-ubuntu:5.1'
+ 'dockerGo': 'adguard/golang-ubuntu:5.2'
diff --git a/bamboo-specs/test.yaml b/bamboo-specs/test.yaml
index fe26bd10..81796e1f 100644
--- a/bamboo-specs/test.yaml
+++ b/bamboo-specs/test.yaml
@@ -5,7 +5,7 @@
'key': 'AHBRTSPECS'
'name': 'AdGuard Home - Build and run tests'
'variables':
- 'dockerGo': 'adguard/golang-ubuntu:5.1'
+ 'dockerGo': 'adguard/golang-ubuntu:5.2'
'stages':
- 'Tests':
diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index e059c9f4..b986dea1 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -215,6 +215,7 @@
"example_upstream_udp": "regular DNS (over UDP, hostname);",
"example_upstream_dot": "encrypted <0>DNS-over-TLS0>;",
"example_upstream_doh": "encrypted <0>DNS-over-HTTPS0>;",
+ "example_upstream_doh3": "encrypted DNS-over-HTTPS with forced <0>HTTP/30> and no fallback to HTTP/2 or below;",
"example_upstream_doq": "encrypted <0>DNS-over-QUIC0>;",
"example_upstream_sdns": "<0>DNS Stamps0> for <1>DNSCrypt1> or <2>DNS-over-HTTPS2> resolvers;",
"example_upstream_tcp": "regular DNS (over TCP);",
@@ -605,7 +606,7 @@
"blocklist": "Blocklist",
"milliseconds_abbreviation": "ms",
"cache_size": "Cache size",
- "cache_size_desc": "DNS cache size (in bytes).",
+ "cache_size_desc": "DNS cache size (in bytes). To disable caching, leave empty.",
"cache_ttl_min_override": "Override minimum TTL",
"cache_ttl_max_override": "Override maximum TTL",
"enter_cache_size": "Enter cache size (bytes)",
diff --git a/client/src/components/Logs/Cells/ClientCell.js b/client/src/components/Logs/Cells/ClientCell.js
index 669f1c0a..9467f14e 100644
--- a/client/src/components/Logs/Cells/ClientCell.js
+++ b/client/src/components/Logs/Cells/ClientCell.js
@@ -121,7 +121,7 @@ const ClientCell = ({
{options.map(({ name, onClick, disabled }) => (
diff --git a/client/src/components/Logs/Cells/IconTooltip.css b/client/src/components/Logs/Cells/IconTooltip.css
index da7e251d..245c14d9 100644
--- a/client/src/components/Logs/Cells/IconTooltip.css
+++ b/client/src/components/Logs/Cells/IconTooltip.css
@@ -50,9 +50,30 @@
}
@media (max-width: 1024px) {
- .grid .key-colon, .grid .title--border {
+ .grid .title--border {
+ margin-bottom: 4px;
font-weight: 600;
}
+
+ .grid .key-colon {
+ margin-right: 4px;
+ color: var(--gray-8);
+ }
+
+ .grid__row {
+ display: flex;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ margin-bottom: 2px;
+ font-size: 14px;
+ word-break: break-all;
+ overflow: hidden;
+ }
+
+ .grid__row .filteringRules__filter,
+ .grid__row .filteringRules {
+ margin-bottom: 0;
+ }
}
@media (max-width: 767.98px) {
@@ -100,7 +121,7 @@
}
.title--border {
- padding-top: 2rem;
+ padding-top: 1rem;
}
.title--border:before {
@@ -109,7 +130,7 @@
left: 0;
border-top: 0.5px solid var(--gray-d8) !important;
width: 100%;
- margin-top: -1rem;
+ margin-top: -0.5rem;
}
.icon-cross {
diff --git a/client/src/components/Logs/Cells/index.js b/client/src/components/Logs/Cells/index.js
index 05c8bf03..aaffeb07 100644
--- a/client/src/components/Logs/Cells/index.js
+++ b/client/src/components/Logs/Cells/index.js
@@ -146,7 +146,7 @@ const Row = memo(({
type="button"
className={
classNames(
- 'button-action--arrow-option',
+ 'button-action--arrow-option mb-1',
{ 'bg--danger': !isBlocked },
{ 'bg--green': isFiltered },
)}
@@ -158,13 +158,13 @@ const Row = memo(({
);
const blockForClientButton =
{t(blockingForClientKey)}
;
const blockClientButton =
{t(blockingClientKey)}
diff --git a/client/src/components/Logs/Logs.css b/client/src/components/Logs/Logs.css
index 788eef56..d365478b 100644
--- a/client/src/components/Logs/Logs.css
+++ b/client/src/components/Logs/Logs.css
@@ -312,8 +312,8 @@
border: 0;
display: block;
width: 100%;
- padding-top: 0.5rem;
- padding-bottom: 0.5rem;
+ padding-top: 0.2rem;
+ padding-bottom: 0.2rem;
text-align: center;
font-weight: 700;
color: inherit;
diff --git a/client/src/components/Logs/index.js b/client/src/components/Logs/index.js
index 2bf89995..e9ec100b 100644
--- a/client/src/components/Logs/index.js
+++ b/client/src/components/Logs/index.js
@@ -47,17 +47,20 @@ const processContent = (data) => Object.entries(data)
keyClass = '';
}
- return isHidden ? null :
-
+
- {isButton ? value : key}
+ })}
+ >
+ {isButton ? value : key}
+
+
+ {(isTitle || isButton || isBoolean) ? '' : value || '—'}
+
-
- {(isTitle || isButton || isBoolean) ? '' : value || '—'}
-
-
;
+ );
});
const Logs = () => {
diff --git a/client/src/components/Settings/Dns/Upstream/Examples.js b/client/src/components/Settings/Dns/Upstream/Examples.js
index c17e9456..a975e444 100644
--- a/client/src/components/Settings/Dns/Upstream/Examples.js
+++ b/client/src/components/Settings/Dns/Upstream/Examples.js
@@ -57,6 +57,22 @@ const Examples = (props) => (
example_upstream_doh
+
+ h3://unfiltered.adguard-dns.com/dns-query
:
+ HTTP/3
+ ,
+ ]}
+ >
+ example_upstream_doh3
+
+
quic://unfiltered.adguard-dns.com
: client
// ipToRC is the IP address to *RuntimeClient map.
+ //
+ // TODO(e.burkov): Use map[netip.Addr]struct{} instead.
ipToRC *netutil.IPMap
lock sync.Mutex
diff --git a/internal/home/clients_test.go b/internal/home/clients_test.go
index 5b4ccdd3..00148c26 100644
--- a/internal/home/clients_test.go
+++ b/internal/home/clients_test.go
@@ -2,6 +2,7 @@ package home
import (
"net"
+ "net/netip"
"os"
"runtime"
"testing"
@@ -287,10 +288,10 @@ func TestClientsAddExisting(t *testing.T) {
DBFilePath: "leases.db",
Conf4: dhcpd.V4ServerConf{
Enabled: true,
- GatewayIP: net.IP{1, 2, 3, 1},
- SubnetMask: net.IP{255, 255, 255, 0},
- RangeStart: net.IP{1, 2, 3, 2},
- RangeEnd: net.IP{1, 2, 3, 10},
+ GatewayIP: netip.MustParseAddr("1.2.3.1"),
+ SubnetMask: netip.MustParseAddr("255.255.255.0"),
+ RangeStart: netip.MustParseAddr("1.2.3.2"),
+ RangeEnd: netip.MustParseAddr("1.2.3.10"),
},
}
diff --git a/internal/home/config.go b/internal/home/config.go
index 598baf81..7df4f853 100644
--- a/internal/home/config.go
+++ b/internal/home/config.go
@@ -3,7 +3,7 @@ package home
import (
"bytes"
"fmt"
- "net"
+ "net/netip"
"os"
"path/filepath"
"sync"
@@ -85,19 +85,28 @@ type configuration struct {
// It's reset after config is parsed
fileData []byte
- 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
- BetaBindPort int `yaml:"beta_bind_port"` // BetaBindPort is the port for new client
- Users []webUser `yaml:"users"` // Users that can access HTTP server
+ // BindHost is the address for the web interface server to listen on.
+ BindHost netip.Addr `yaml:"bind_host"`
+ // BindPort is the port for the web interface server to listen on.
+ BindPort int `yaml:"bind_port"`
+ // BetaBindPort is the port for the new client's web interface server to
+ // listen on.
+ BetaBindPort int `yaml:"beta_bind_port"`
+
+ // Users are the clients capable for accessing the web interface.
+ Users []webUser `yaml:"users"`
// AuthAttempts is the maximum number of failed login attempts a user
// can do before being blocked.
AuthAttempts uint `yaml:"auth_attempts"`
// AuthBlockMin is the duration, in minutes, of the block of new login
// attempts after AuthAttempts unsuccessful login attempts.
- AuthBlockMin uint `yaml:"block_auth_min"`
- ProxyURL string `yaml:"http_proxy"` // Proxy address for our HTTP client
- Language string `yaml:"language"` // two-letter ISO 639-1 language code
- DebugPProf bool `yaml:"debug_pprof"` // Enable pprof HTTP server on port 6060
+ AuthBlockMin uint `yaml:"block_auth_min"`
+ // ProxyURL is the address of proxy server for the internal HTTP client.
+ ProxyURL string `yaml:"http_proxy"`
+ // Language is a two-letter ISO 639-1 language code.
+ Language string `yaml:"language"`
+ // DebugPProf defines if the profiling HTTP handler will listen on :6060.
+ DebugPProf bool `yaml:"debug_pprof"`
// TTL for a web session (in hours)
// An active session is automatically refreshed once a day.
@@ -112,7 +121,7 @@ type configuration struct {
//
// TODO(e.burkov): Move all the filtering configuration fields into the
// only configuration subsection covering the changes with a single
- // migration.
+ // migration. Also keep the blocked services in mind.
Filters []filtering.FilterYAML `yaml:"filters"`
WhitelistFilters []filtering.FilterYAML `yaml:"whitelist_filters"`
UserRules []string `yaml:"user_rules"`
@@ -135,18 +144,26 @@ type configuration struct {
// field ordering is important -- yaml fields will mirror ordering from here
type dnsConfig struct {
- BindHosts []net.IP `yaml:"bind_hosts"`
- Port int `yaml:"port"`
+ BindHosts []netip.Addr `yaml:"bind_hosts"`
+ Port int `yaml:"port"`
- // time interval for statistics (in days)
+ // StatsInterval is the time interval for flushing statistics to the disk in
+ // days.
StatsInterval uint32 `yaml:"statistics_interval"`
- QueryLogEnabled bool `yaml:"querylog_enabled"` // if true, query log is enabled
- QueryLogFileEnabled bool `yaml:"querylog_file_enabled"` // if true, query log will be written to a file
+ // QueryLogEnabled defines if the query log is enabled.
+ QueryLogEnabled bool `yaml:"querylog_enabled"`
+ // QueryLogFileEnabled defines, if the query log is written to the file.
+ QueryLogFileEnabled bool `yaml:"querylog_file_enabled"`
// QueryLogInterval is the interval for query log's files rotation.
- QueryLogInterval timeutil.Duration `yaml:"querylog_interval"`
- QueryLogMemSize uint32 `yaml:"querylog_size_memory"` // number of entries kept in memory before they are flushed to disk
- AnonymizeClientIP bool `yaml:"anonymize_client_ip"` // anonymize clients' IP addresses in logs and stats
+ QueryLogInterval timeutil.Duration `yaml:"querylog_interval"`
+ // QueryLogMemSize is the number of entries kept in memory before they are
+ // flushed to disk.
+ QueryLogMemSize uint32 `yaml:"querylog_size_memory"`
+
+ // AnonymizeClientIP defines if clients' IP addresses should be anonymized
+ // in query log and statistics.
+ AnonymizeClientIP bool `yaml:"anonymize_client_ip"`
dnsforward.FilteringConfig `yaml:",inline"`
@@ -211,12 +228,12 @@ type tlsConfigSettings struct {
var config = &configuration{
BindPort: 3000,
BetaBindPort: 0,
- BindHost: net.IP{0, 0, 0, 0},
+ BindHost: netip.IPv4Unspecified(),
AuthAttempts: 5,
AuthBlockMin: 15,
WebSessionTTLHours: 30 * 24,
DNS: dnsConfig{
- BindHosts: []net.IP{{0, 0, 0, 0}},
+ BindHosts: []netip.Addr{netip.IPv4Unspecified()},
Port: defaultPortDNS,
StatsInterval: 1,
QueryLogEnabled: true,
@@ -236,6 +253,7 @@ var config = &configuration{
},
TrustedProxies: []string{"127.0.0.0/8", "::1/128"},
+ CacheSize: 4 * 1024 * 1024,
// set default maximum concurrent queries to 300
// we introduced a default limit due to this:
diff --git a/internal/home/control.go b/internal/home/control.go
index 54d2efb1..5e4e6df2 100644
--- a/internal/home/control.go
+++ b/internal/home/control.go
@@ -2,8 +2,8 @@ package home
import (
"fmt"
- "net"
"net/http"
+ "net/netip"
"net/url"
"runtime"
"strings"
@@ -20,11 +20,11 @@ import (
// appendDNSAddrs is a convenient helper for appending a formatted form of DNS
// addresses to a slice of strings.
-func appendDNSAddrs(dst []string, addrs ...net.IP) (res []string) {
+func appendDNSAddrs(dst []string, addrs ...netip.Addr) (res []string) {
for _, addr := range addrs {
var hostport string
if config.DNS.Port != defaultPortDNS {
- hostport = netutil.JoinHostPort(addr.String(), config.DNS.Port)
+ hostport = netip.AddrPortFrom(addr, uint16(config.DNS.Port)).String()
} else {
hostport = addr.String()
}
@@ -38,7 +38,7 @@ func appendDNSAddrs(dst []string, addrs ...net.IP) (res []string) {
// appendDNSAddrsWithIfaces formats and appends all DNS addresses from src to
// dst. It also adds the IP addresses of all network interfaces if src contains
// an unspecified IP address.
-func appendDNSAddrsWithIfaces(dst []string, src []net.IP) (res []string, err error) {
+func appendDNSAddrsWithIfaces(dst []string, src []netip.Addr) (res []string, err error) {
ifacesAdded := false
for _, h := range src {
if !h.IsUnspecified() {
@@ -71,7 +71,9 @@ func appendDNSAddrsWithIfaces(dst []string, src []net.IP) (res []string, err err
// on, including the addresses on all interfaces in cases of unspecified IPs.
func collectDNSAddresses() (addrs []string, err error) {
if hosts := config.DNS.BindHosts; len(hosts) == 0 {
- addrs = appendDNSAddrs(addrs, net.IP{127, 0, 0, 1})
+ addr := aghnet.IPv4Localhost()
+
+ addrs = appendDNSAddrs(addrs, addr)
} else {
addrs, err = appendDNSAddrsWithIfaces(addrs, hosts)
if err != nil {
@@ -320,6 +322,28 @@ func handleHTTPSRedirect(w http.ResponseWriter, r *http.Request) (ok bool) {
return false
}
+ var serveHTTP3 bool
+ var portHTTPS int
+ func() {
+ config.RLock()
+ defer config.RUnlock()
+
+ serveHTTP3, portHTTPS = config.DNS.ServeHTTP3, config.TLS.PortHTTPS
+ }()
+
+ respHdr := w.Header()
+
+ // Let the browser know that server supports HTTP/3.
+ //
+ // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Alt-Svc.
+ //
+ // TODO(a.garipov): Consider adding a configurable max-age. Currently, the
+ // default is 24 hours.
+ if serveHTTP3 {
+ altSvc := fmt.Sprintf(`h3=":%d"`, portHTTPS)
+ respHdr.Set(aghhttp.HdrNameAltSvc, altSvc)
+ }
+
if r.TLS == nil && web.forceHTTPS {
hostPort := host
if port := web.conf.PortHTTPS; port != defaultPortHTTPS {
@@ -346,8 +370,9 @@ func handleHTTPSRedirect(w http.ResponseWriter, r *http.Request) (ok bool) {
Scheme: aghhttp.SchemeHTTP,
Host: r.Host,
}
- w.Header().Set("Access-Control-Allow-Origin", originURL.String())
- w.Header().Set("Vary", "Origin")
+
+ respHdr.Set(aghhttp.HdrNameAccessControlAllowOrigin, originURL.String())
+ respHdr.Set(aghhttp.HdrNameVary, aghhttp.HdrNameOrigin)
return true
}
diff --git a/internal/home/controlinstall.go b/internal/home/controlinstall.go
index 7df8d320..0cbddf00 100644
--- a/internal/home/controlinstall.go
+++ b/internal/home/controlinstall.go
@@ -5,8 +5,8 @@ import (
"encoding/json"
"fmt"
"io"
- "net"
"net/http"
+ "net/netip"
"os"
"os/exec"
"path/filepath"
@@ -64,9 +64,9 @@ func (web *Web) handleInstallGetAddresses(w http.ResponseWriter, r *http.Request
}
type checkConfReqEnt struct {
- IP net.IP `json:"ip"`
- Port int `json:"port"`
- Autofix bool `json:"autofix"`
+ IP netip.Addr `json:"ip"`
+ Port int `json:"port"`
+ Autofix bool `json:"autofix"`
}
type checkConfReq struct {
@@ -117,7 +117,7 @@ func (req *checkConfReq) validateWeb(tcpPorts aghalg.UniqChecker[tcpPort]) (err
// unbound after install.
}
- return aghnet.CheckPort("tcp", req.Web.IP, portInt)
+ return aghnet.CheckPort("tcp", netip.AddrPortFrom(req.Web.IP, uint16(portInt)))
}
// validateDNS returns error if the DNS part of the initial configuration can't
@@ -142,13 +142,13 @@ func (req *checkConfReq) validateDNS(
return false, err
}
- err = aghnet.CheckPort("tcp", req.DNS.IP, port)
+ err = aghnet.CheckPort("tcp", netip.AddrPortFrom(req.DNS.IP, uint16(port)))
if err != nil {
return false, err
}
}
- err = aghnet.CheckPort("udp", req.DNS.IP, port)
+ err = aghnet.CheckPort("udp", netip.AddrPortFrom(req.DNS.IP, uint16(port)))
if !aghnet.IsAddrInUse(err) {
return false, err
}
@@ -160,7 +160,7 @@ func (req *checkConfReq) validateDNS(
log.Error("disabling DNSStubListener: %s", err)
}
- err = aghnet.CheckPort("udp", req.DNS.IP, port)
+ err = aghnet.CheckPort("udp", netip.AddrPortFrom(req.DNS.IP, uint16(port)))
canAutofix = false
}
@@ -196,7 +196,7 @@ func (web *Web) handleInstallCheckConfig(w http.ResponseWriter, r *http.Request)
// handleStaticIP - handles static IP request
// It either checks if we have a static IP
// Or if set=true, it tries to set it
-func handleStaticIP(ip net.IP, set bool) staticIPJSON {
+func handleStaticIP(ip netip.Addr, set bool) staticIPJSON {
resp := staticIPJSON{}
interfaceName := aghnet.InterfaceByIP(ip)
@@ -304,8 +304,8 @@ func disableDNSStubListener() error {
}
type applyConfigReqEnt struct {
- IP net.IP `json:"ip"`
- Port int `json:"port"`
+ IP netip.Addr `json:"ip"`
+ Port int `json:"port"`
}
type applyConfigReq struct {
@@ -397,14 +397,14 @@ func (web *Web) handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
return
}
- err = aghnet.CheckPort("udp", req.DNS.IP, req.DNS.Port)
+ err = aghnet.CheckPort("udp", netip.AddrPortFrom(req.DNS.IP, uint16(req.DNS.Port)))
if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
return
}
- err = aghnet.CheckPort("tcp", req.DNS.IP, req.DNS.Port)
+ err = aghnet.CheckPort("tcp", netip.AddrPortFrom(req.DNS.IP, uint16(req.DNS.Port)))
if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
@@ -417,14 +417,14 @@ func (web *Web) handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
Context.firstRun = false
config.BindHost = req.Web.IP
config.BindPort = req.Web.Port
- config.DNS.BindHosts = []net.IP{req.DNS.IP}
+ config.DNS.BindHosts = []netip.Addr{req.DNS.IP}
config.DNS.Port = req.DNS.Port
// TODO(e.burkov): StartMods() should be put in a separate goroutine at the
// moment we'll allow setting up TLS in the initial configuration or the
// configuration itself will use HTTPS protocol, because the underlying
// functions potentially restart the HTTPS server.
- err = StartMods()
+ err = startMods()
if err != nil {
Context.firstRun = true
copyInstallSettings(config, curConfig)
@@ -490,9 +490,9 @@ func decodeApplyConfigReq(r io.Reader) (req *applyConfigReq, restartHTTP bool, e
return nil, false, errors.Error("ports cannot be 0")
}
- restartHTTP = !config.BindHost.Equal(req.Web.IP) || config.BindPort != req.Web.Port
+ restartHTTP = config.BindHost != req.Web.IP || config.BindPort != req.Web.Port
if restartHTTP {
- err = aghnet.CheckPort("tcp", req.Web.IP, req.Web.Port)
+ err = aghnet.CheckPort("tcp", netip.AddrPortFrom(req.Web.IP, uint16(req.Web.Port)))
if err != nil {
return nil, false, fmt.Errorf(
"checking address %s:%d: %w",
@@ -518,9 +518,9 @@ func (web *Web) registerInstallHandlers() {
// TODO(e.burkov): This should removed with the API v1 when the appropriate
// functionality will appear in default checkConfigReqEnt.
type checkConfigReqEntBeta struct {
- IP []net.IP `json:"ip"`
- Port int `json:"port"`
- Autofix bool `json:"autofix"`
+ IP []netip.Addr `json:"ip"`
+ Port int `json:"port"`
+ Autofix bool `json:"autofix"`
}
// checkConfigReqBeta is a struct representing new client's config check request
@@ -590,8 +590,8 @@ func (web *Web) handleInstallCheckConfigBeta(w http.ResponseWriter, r *http.Requ
// TODO(e.burkov): This should removed with the API v1 when the appropriate
// functionality will appear in default applyConfigReqEnt.
type applyConfigReqEntBeta struct {
- IP []net.IP `json:"ip"`
- Port int `json:"port"`
+ IP []netip.Addr `json:"ip"`
+ Port int `json:"port"`
}
// applyConfigReqBeta is a struct representing new client's config setting
diff --git a/internal/home/dns.go b/internal/home/dns.go
index da462876..6c0d6531 100644
--- a/internal/home/dns.go
+++ b/internal/home/dns.go
@@ -3,6 +3,7 @@ package home
import (
"fmt"
"net"
+ "net/netip"
"net/url"
"os"
"path/filepath"
@@ -164,33 +165,27 @@ func onDNSRequest(pctx *proxy.DNSContext) {
}
}
-func ipsToTCPAddrs(ips []net.IP, port int) (tcpAddrs []*net.TCPAddr) {
+func ipsToTCPAddrs(ips []netip.Addr, port int) (tcpAddrs []*net.TCPAddr) {
if ips == nil {
return nil
}
- tcpAddrs = make([]*net.TCPAddr, len(ips))
- for i, ip := range ips {
- tcpAddrs[i] = &net.TCPAddr{
- IP: ip,
- Port: port,
- }
+ tcpAddrs = make([]*net.TCPAddr, 0, len(ips))
+ for _, ip := range ips {
+ tcpAddrs = append(tcpAddrs, net.TCPAddrFromAddrPort(netip.AddrPortFrom(ip, uint16(port))))
}
return tcpAddrs
}
-func ipsToUDPAddrs(ips []net.IP, port int) (udpAddrs []*net.UDPAddr) {
+func ipsToUDPAddrs(ips []netip.Addr, port int) (udpAddrs []*net.UDPAddr) {
if ips == nil {
return nil
}
- udpAddrs = make([]*net.UDPAddr, len(ips))
- for i, ip := range ips {
- udpAddrs[i] = &net.UDPAddr{
- IP: ip,
- Port: port,
- }
+ udpAddrs = make([]*net.UDPAddr, 0, len(ips))
+ for _, ip := range ips {
+ udpAddrs = append(udpAddrs, net.UDPAddrFromAddrPort(netip.AddrPortFrom(ip, uint16(port))))
}
return udpAddrs
@@ -200,7 +195,7 @@ func generateServerConfig() (newConf dnsforward.ServerConfig, err error) {
dnsConf := config.DNS
hosts := dnsConf.BindHosts
if len(hosts) == 0 {
- hosts = []net.IP{{127, 0, 0, 1}}
+ hosts = []netip.Addr{aghnet.IPv4Localhost()}
}
newConf = dnsforward.ServerConfig{
@@ -257,7 +252,7 @@ func generateServerConfig() (newConf dnsforward.ServerConfig, err error) {
return newConf, nil
}
-func newDNSCrypt(hosts []net.IP, tlsConf tlsConfigSettings) (dnscc dnsforward.DNSCryptConfig, err error) {
+func newDNSCrypt(hosts []netip.Addr, tlsConf tlsConfigSettings) (dnscc dnsforward.DNSCryptConfig, err error) {
if tlsConf.DNSCryptConfigFile == "" {
return dnscc, errors.Error("no dnscrypt_config_file")
}
diff --git a/internal/home/home.go b/internal/home/home.go
index 49e348a5..c8bd992e 100644
--- a/internal/home/home.go
+++ b/internal/home/home.go
@@ -10,6 +10,7 @@ import (
"net"
"net/http"
"net/http/pprof"
+ "net/netip"
"net/url"
"os"
"os/signal"
@@ -58,7 +59,7 @@ type homeContext struct {
auth *Auth // HTTP authentication module
filters *filtering.DNSFilter // DNS filtering module
web *Web // Web (HTTP, HTTPS) module
- tls *TLSMod // TLS module
+ tls *tlsManager // TLS module
// etcHosts is an IP-hostname pairs set taken from system configuration
// (e.g. /etc/hosts) files.
etcHosts *aghnet.HostsContainer
@@ -97,9 +98,15 @@ var Context homeContext
// Main is the entry point
func Main(clientBuildFS fs.FS) {
- // config can be specified, which reads options from there, but other command line flags have to override config values
- // therefore, we must do it manually instead of using a lib
- args := loadOptions()
+ initCmdLineOpts()
+
+ // The configuration file path can be overridden, but other command-line
+ // options have to override config values. Therefore, do it manually
+ // instead of using package flag.
+ //
+ // TODO(a.garipov): The comment above is most likely false. Replace with
+ // package flag.
+ opts := loadCmdLineOpts()
Context.appSignalChannel = make(chan os.Signal)
signal.Notify(Context.appSignalChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT)
@@ -110,7 +117,7 @@ func Main(clientBuildFS fs.FS) {
switch sig {
case syscall.SIGHUP:
Context.clients.Reload()
- Context.tls.Reload()
+ Context.tls.reload()
default:
cleanup(context.Background())
@@ -120,26 +127,18 @@ func Main(clientBuildFS fs.FS) {
}
}()
- if args.serviceControlAction != "" {
- handleServiceControlAction(args, clientBuildFS)
+ if opts.serviceControlAction != "" {
+ handleServiceControlAction(opts, clientBuildFS)
return
}
// run the protection
- run(args, clientBuildFS)
+ run(opts, clientBuildFS)
}
-func setupContext(args options) {
- Context.runningAsService = args.runningAsService
- Context.disableUpdate = args.disableUpdate ||
- version.Channel() == version.ChannelDevelopment
-
- Context.firstRun = detectFirstRun()
- if Context.firstRun {
- log.Info("This is the first time AdGuard Home is launched")
- checkPermissions()
- }
+func setupContext(opts options) {
+ setupContextFlags(opts)
switch version.Channel() {
case version.ChannelEdge, version.ChannelDevelopment:
@@ -148,7 +147,7 @@ func setupContext(args options) {
// Go on.
}
- Context.tlsRoots = LoadSystemRootCAs()
+ Context.tlsRoots = aghtls.SystemRootCAs()
Context.transport = &http.Transport{
DialContext: customDialContext,
Proxy: getHTTPProxy,
@@ -174,13 +173,13 @@ func setupContext(args options) {
os.Exit(1)
}
- if args.checkConfig {
+ if opts.checkConfig {
log.Info("configuration file is ok")
os.Exit(0)
}
- if !args.noEtcHosts && config.Clients.Sources.HostsFile {
+ if !opts.noEtcHosts && config.Clients.Sources.HostsFile {
err = setupHostsContainer()
fatalOnError(err)
}
@@ -189,6 +188,24 @@ func setupContext(args options) {
Context.mux = http.NewServeMux()
}
+// setupContextFlags sets global flags and prints their status to the log.
+func setupContextFlags(opts options) {
+ Context.firstRun = detectFirstRun()
+ if Context.firstRun {
+ log.Info("This is the first time AdGuard Home is launched")
+ checkPermissions()
+ }
+
+ Context.runningAsService = opts.runningAsService
+ // Don't print the runningAsService flag, since that has already been done
+ // in [run].
+
+ Context.disableUpdate = opts.disableUpdate || version.Channel() == version.ChannelDevelopment
+ if Context.disableUpdate {
+ log.Info("AdGuard Home updates are disabled")
+ }
+}
+
// logIfUnsupported logs a formatted warning if the error is one of the
// unsupported errors and returns nil. If err is nil, logIfUnsupported returns
// nil. Otherwise, it returns err.
@@ -270,7 +287,7 @@ func setupHostsContainer() (err error) {
return nil
}
-func setupConfig(args options) (err error) {
+func setupConfig(opts options) (err error) {
config.DNS.DnsfilterConf.EtcHosts = Context.etcHosts
config.DNS.DnsfilterConf.ConfigModified = onConfigModified
config.DNS.DnsfilterConf.HTTPRegister = httpRegister
@@ -312,9 +329,9 @@ func setupConfig(args options) (err error) {
Context.clients.Init(config.Clients.Persistent, Context.dhcpServer, Context.etcHosts, arpdb)
- if args.bindPort != 0 {
+ if opts.bindPort != 0 {
tcpPorts := aghalg.UniqChecker[tcpPort]{}
- addPorts(tcpPorts, tcpPort(args.bindPort), tcpPort(config.BetaBindPort))
+ addPorts(tcpPorts, tcpPort(opts.bindPort), tcpPort(config.BetaBindPort))
udpPorts := aghalg.UniqChecker[udpPort]{}
addPorts(udpPorts, udpPort(config.DNS.Port))
@@ -336,23 +353,23 @@ func setupConfig(args options) (err error) {
return fmt.Errorf("validating udp ports: %w", err)
}
- config.BindPort = args.bindPort
+ config.BindPort = opts.bindPort
}
// override bind host/port from the console
- if args.bindHost != nil {
- config.BindHost = args.bindHost
+ if opts.bindHost.IsValid() {
+ config.BindHost = opts.bindHost
}
- if len(args.pidFile) != 0 && writePIDFile(args.pidFile) {
- Context.pidFileName = args.pidFile
+ if len(opts.pidFile) != 0 && writePIDFile(opts.pidFile) {
+ Context.pidFileName = opts.pidFile
}
return nil
}
-func initWeb(args options, clientBuildFS fs.FS) (web *Web, err error) {
+func initWeb(opts options, clientBuildFS fs.FS) (web *Web, err error) {
var clientFS, clientBetaFS fs.FS
- if args.localFrontend {
+ if opts.localFrontend {
log.Info("warning: using local frontend files")
clientFS = os.DirFS("build/static")
@@ -406,24 +423,24 @@ func fatalOnError(err error) {
}
// run configures and starts AdGuard Home.
-func run(args options, clientBuildFS fs.FS) {
+func run(opts options, clientBuildFS fs.FS) {
// configure config filename
- initConfigFilename(args)
+ initConfigFilename(opts)
// configure working dir and config path
- initWorkingDir(args)
+ initWorkingDir(opts)
// configure log level and output
- configureLogger(args)
+ configureLogger(opts)
// Print the first message after logger is configured.
log.Info(version.Full())
log.Debug("current working directory is %s", Context.workDir)
- if args.runningAsService {
+ if opts.runningAsService {
log.Info("AdGuard Home is running as a service")
}
- setupContext(args)
+ setupContext(opts)
err := configureOS(config)
fatalOnError(err)
@@ -433,7 +450,7 @@ func run(args options, clientBuildFS fs.FS) {
// but also avoid relying on automatic Go init() function
filtering.InitModule()
- err = setupConfig(args)
+ err = setupConfig(opts)
fatalOnError(err)
if !Context.firstRun {
@@ -462,7 +479,7 @@ func run(args options, clientBuildFS fs.FS) {
}
sessFilename := filepath.Join(Context.getDataDir(), "sessions.db")
- GLMode = args.glinetMode
+ GLMode = opts.glinetMode
var rateLimiter *authRateLimiter
if config.AuthAttempts > 0 && config.AuthBlockMin > 0 {
rateLimiter = newAuthRateLimiter(
@@ -484,19 +501,19 @@ func run(args options, clientBuildFS fs.FS) {
}
config.Users = nil
- Context.tls = tlsCreate(config.TLS)
- if Context.tls == nil {
- log.Fatalf("Can't initialize TLS module")
+ Context.tls, err = newTLSManager(config.TLS)
+ if err != nil {
+ log.Fatalf("initializing tls: %s", err)
}
- Context.web, err = initWeb(args, clientBuildFS)
+ Context.web, err = initWeb(opts, clientBuildFS)
fatalOnError(err)
if !Context.firstRun {
err = initDNSServer()
fatalOnError(err)
- Context.tls.Start()
+ Context.tls.start()
go func() {
serr := startDNSServer()
@@ -520,20 +537,22 @@ func run(args options, clientBuildFS fs.FS) {
select {}
}
-// StartMods initializes and starts the DNS server after installation.
-func StartMods() error {
+// startMods initializes and starts the DNS server after installation.
+func startMods() error {
err := initDNSServer()
if err != nil {
return err
}
- Context.tls.Start()
+ Context.tls.start()
err = startDNSServer()
if err != nil {
closeDNSServer()
+
return err
}
+
return nil
}
@@ -546,7 +565,7 @@ func checkPermissions() {
}
// We should check if AdGuard Home is able to bind to port 53
- err := aghnet.CheckPort("tcp", net.IP{127, 0, 0, 1}, defaultPortDNS)
+ err := aghnet.CheckPort("tcp", netip.AddrPortFrom(aghnet.IPv4Localhost(), defaultPortDNS))
if err != nil {
if errors.Is(err, os.ErrPermission) {
log.Fatal(`Permission check failed.
@@ -581,10 +600,10 @@ func writePIDFile(fn string) bool {
return true
}
-func initConfigFilename(args options) {
+func initConfigFilename(opts options) {
// config file path can be overridden by command-line arguments:
- if args.configFilename != "" {
- Context.configFilename = args.configFilename
+ if opts.confFilename != "" {
+ Context.configFilename = opts.confFilename
} else {
// Default config file name
Context.configFilename = "AdGuardHome.yaml"
@@ -593,15 +612,15 @@ func initConfigFilename(args options) {
// initWorkingDir initializes the workDir
// if no command-line arguments specified, we use the directory where our binary file is located
-func initWorkingDir(args options) {
+func initWorkingDir(opts options) {
execPath, err := os.Executable()
if err != nil {
panic(err)
}
- if args.workDir != "" {
+ if opts.workDir != "" {
// If there is a custom config file, use it's directory as our working dir
- Context.workDir = args.workDir
+ Context.workDir = opts.workDir
} else {
Context.workDir = filepath.Dir(execPath)
}
@@ -615,15 +634,15 @@ func initWorkingDir(args options) {
}
// configureLogger configures logger level and output
-func configureLogger(args options) {
+func configureLogger(opts options) {
ls := getLogSettings()
// command-line arguments can override config settings
- if args.verbose || config.Verbose {
+ if opts.verbose || config.Verbose {
ls.Verbose = true
}
- if args.logFile != "" {
- ls.File = args.logFile
+ if opts.logFile != "" {
+ ls.File = opts.logFile
} else if config.File != "" {
ls.File = config.File
}
@@ -644,7 +663,7 @@ func configureLogger(args options) {
// happen pretty quickly.
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
- if args.runningAsService && ls.File == "" && runtime.GOOS == "windows" {
+ if opts.runningAsService && ls.File == "" && runtime.GOOS == "windows" {
// When running as a Windows service, use eventlog by default if nothing
// else is configured. Otherwise, we'll simply lose the log output.
ls.File = configSyslog
@@ -717,7 +736,6 @@ func cleanup(ctx context.Context) {
}
if Context.tls != nil {
- Context.tls.Close()
Context.tls = nil
}
}
@@ -727,32 +745,37 @@ func cleanupAlways() {
if len(Context.pidFileName) != 0 {
_ = os.Remove(Context.pidFileName)
}
- log.Info("Stopped")
+
+ log.Info("stopped")
}
func exitWithError() {
os.Exit(64)
}
-// loadOptions reads command line arguments and initializes configuration
-func loadOptions() options {
- o, f, err := parse(os.Args[0], os.Args[1:])
-
+// loadCmdLineOpts reads command line arguments and initializes configuration
+// from them. If there is an error or an effect, loadCmdLineOpts processes them
+// and exits.
+func loadCmdLineOpts() (opts options) {
+ opts, eff, err := parseCmdOpts(os.Args[0], os.Args[1:])
if err != nil {
log.Error(err.Error())
- _ = printHelp(os.Args[0])
+ printHelp(os.Args[0])
+
exitWithError()
- } else if f != nil {
- err = f()
+ }
+
+ if eff != nil {
+ err = eff()
if err != nil {
log.Error(err.Error())
exitWithError()
- } else {
- os.Exit(0)
}
+
+ os.Exit(0)
}
- return o
+ return opts
}
// printWebAddrs prints addresses built from proto, addr, and an appropriate
@@ -901,6 +924,6 @@ func getTLSCiphers() (cipherIds []uint16, err error) {
return aghtls.SaferCipherSuites(), nil
} else {
log.Info("Overriding TLS Ciphers : %s", config.TLS.OverrideTLSCiphers)
- return aghtls.ParseCipherIDs(config.TLS.OverrideTLSCiphers)
+ return aghtls.ParseCiphers(config.TLS.OverrideTLSCiphers)
}
}
diff --git a/internal/home/home_test.go b/internal/home/home_test.go
new file mode 100644
index 00000000..1a611588
--- /dev/null
+++ b/internal/home/home_test.go
@@ -0,0 +1,12 @@
+package home
+
+import (
+ "testing"
+
+ "github.com/AdguardTeam/AdGuardHome/internal/aghtest"
+)
+
+func TestMain(m *testing.M) {
+ aghtest.DiscardLogOutput(m)
+ initCmdLineOpts()
+}
diff --git a/internal/home/mobileconfig_test.go b/internal/home/mobileconfig_test.go
index 5230a2ac..3587154f 100644
--- a/internal/home/mobileconfig_test.go
+++ b/internal/home/mobileconfig_test.go
@@ -3,12 +3,11 @@ package home
import (
"bytes"
"encoding/json"
- "net"
"net/http"
"net/http/httptest"
+ "net/netip"
"testing"
- "github.com/AdguardTeam/golibs/netutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"howett.net/plist"
@@ -28,12 +27,12 @@ func setupDNSIPs(t testing.TB) {
config = &configuration{
DNS: dnsConfig{
- BindHosts: []net.IP{netutil.IPv4Zero()},
+ BindHosts: []netip.Addr{netip.IPv4Unspecified()},
Port: defaultPortDNS,
},
}
- Context.tls = &TLSMod{}
+ Context.tls = &tlsManager{}
}
func TestHandleMobileConfigDoH(t *testing.T) {
@@ -66,7 +65,7 @@ func TestHandleMobileConfigDoH(t *testing.T) {
oldTLSConf := Context.tls
t.Cleanup(func() { Context.tls = oldTLSConf })
- Context.tls = &TLSMod{conf: tlsConfigSettings{}}
+ Context.tls = &tlsManager{conf: tlsConfigSettings{}}
r, err := http.NewRequest(http.MethodGet, "https://example.com:12345/apple/doh.mobileconfig", nil)
require.NoError(t, err)
@@ -138,7 +137,7 @@ func TestHandleMobileConfigDoT(t *testing.T) {
oldTLSConf := Context.tls
t.Cleanup(func() { Context.tls = oldTLSConf })
- Context.tls = &TLSMod{conf: tlsConfigSettings{}}
+ Context.tls = &tlsManager{conf: tlsConfigSettings{}}
r, err := http.NewRequest(http.MethodGet, "https://example.com:12345/apple/dot.mobileconfig", nil)
require.NoError(t, err)
diff --git a/internal/home/options.go b/internal/home/options.go
index 6f5a4d8d..e14e26f6 100644
--- a/internal/home/options.go
+++ b/internal/home/options.go
@@ -2,33 +2,63 @@ package home
import (
"fmt"
- "net"
+ "net/netip"
"os"
"strconv"
+ "strings"
"github.com/AdguardTeam/AdGuardHome/internal/version"
"github.com/AdguardTeam/golibs/log"
+ "github.com/AdguardTeam/golibs/stringutil"
)
-// options passed from command-line arguments
-type options struct {
- verbose bool // is verbose logging enabled
- configFilename string // path to the config file
- workDir string // path to the working directory where we will store the filters data and the querylog
- bindHost net.IP // host address to bind HTTP server on
- bindPort int // port to serve HTTP pages on
- logFile string // Path to the log file. If empty, write to stdout. If "syslog", writes to syslog
- pidFile string // File name to save PID to
- checkConfig bool // Check configuration and exit
- disableUpdate bool // If set, don't check for updates
+// TODO(a.garipov): Replace with package flag.
- // service control action (see service.ControlAction array + "status" command)
+// options represents the command-line options.
+type options struct {
+ // confFilename is the path to the configuration file.
+ confFilename string
+
+ // workDir is the path to the working directory where AdGuard Home stores
+ // filter data, the query log, and other data.
+ workDir string
+
+ // logFile is the path to the log file. If empty, AdGuard Home writes to
+ // stdout; if "syslog", to syslog.
+ logFile string
+
+ // pidFile is the file name for the file to which the PID is saved.
+ pidFile string
+
+ // serviceControlAction is the service action to perform. See
+ // [service.ControlAction] and [handleServiceControlAction].
serviceControlAction string
- // runningAsService flag is set to true when options are passed from the service runner
+ // bindHost is the address on which to serve the HTTP UI.
+ bindHost netip.Addr
+
+ // bindPort is the port on which to serve the HTTP UI.
+ bindPort int
+
+ // checkConfig is true if the current invocation is only required to check
+ // the configuration file and exit.
+ checkConfig bool
+
+ // disableUpdate, if set, makes AdGuard Home not check for updates.
+ disableUpdate bool
+
+ // verbose shows if verbose logging is enabled.
+ verbose bool
+
+ // runningAsService flag is set to true when options are passed from the
+ // service runner
+ //
+ // TODO(a.garipov): Perhaps this could be determined by a non-empty
+ // serviceControlAction?
runningAsService bool
- glinetMode bool // Activate GL-Inet compatibility mode
+ // glinetMode shows if the GL-Inet compatibility mode is enabled.
+ glinetMode bool
// noEtcHosts flag should be provided when /etc/hosts file shouldn't be
// used.
@@ -39,88 +69,86 @@ type options struct {
localFrontend bool
}
-// functions used for their side-effects
-type effect func() error
-
-type arg struct {
- description string // a short, English description of the argument
- longName string // the name of the argument used after '--'
- shortName string // the name of the argument used after '-'
-
- // only one of updateWithValue, updateNoValue, and effect should be present
-
- updateWithValue func(o options, v string) (options, error) // the mutator for arguments with parameters
- updateNoValue func(o options) (options, error) // the mutator for arguments without parameters
- effect func(o options, exec string) (f effect, err error) // the side-effect closure generator
-
- serialize func(o options) []string // the re-serialization function back to arguments (return nil for omit)
+// initCmdLineOpts completes initialization of the global command-line option
+// slice. It must only be called once.
+func initCmdLineOpts() {
+ // The --help option cannot be put directly into cmdLineOpts, because that
+ // causes initialization cycle due to printHelp referencing cmdLineOpts.
+ cmdLineOpts = append(cmdLineOpts, cmdLineOpt{
+ updateWithValue: nil,
+ updateNoValue: nil,
+ effect: func(o options, exec string) (effect, error) {
+ return func() error { printHelp(exec); exitWithError(); return nil }, nil
+ },
+ serialize: func(o options) (val string, ok bool) { return "", false },
+ description: "Print this help.",
+ longName: "help",
+ shortName: "",
+ })
}
-// {type}SliceOrNil functions check their parameter of type {type}
-// against its zero value and return nil if the parameter value is
-// zero otherwise they return a string slice of the parameter
+// effect is the type for functions used for their side-effects.
+type effect func() (err error)
-func ipSliceOrNil(ip net.IP) []string {
- if ip == nil {
- return nil
- }
+// cmdLineOpt contains the data for a single command-line option. Only one of
+// updateWithValue, updateNoValue, and effect must be present.
+type cmdLineOpt struct {
+ updateWithValue func(o options, v string) (updated options, err error)
+ updateNoValue func(o options) (updated options, err error)
+ effect func(o options, exec string) (eff effect, err error)
- return []string{ip.String()}
+ // serialize is a function that encodes the option into a slice of
+ // command-line arguments, if necessary. If ok is false, this option should
+ // be skipped.
+ serialize func(o options) (val string, ok bool)
+
+ description string
+ longName string
+ shortName string
}
-func stringSliceOrNil(s string) []string {
- if s == "" {
- return nil
- }
+// cmdLineOpts are all command-line options of AdGuard Home.
+var cmdLineOpts = []cmdLineOpt{{
+ updateWithValue: func(o options, v string) (options, error) {
+ o.confFilename = v
+ return o, nil
+ },
+ updateNoValue: nil,
+ effect: nil,
+ serialize: func(o options) (val string, ok bool) {
+ return o.confFilename, o.confFilename != ""
+ },
+ description: "Path to the config file.",
+ longName: "config",
+ shortName: "c",
+}, {
+ updateWithValue: func(o options, v string) (options, error) { o.workDir = v; return o, nil },
+ updateNoValue: nil,
+ effect: nil,
+ serialize: func(o options) (val string, ok bool) { return o.workDir, o.workDir != "" },
+ description: "Path to the working directory.",
+ longName: "work-dir",
+ shortName: "w",
+}, {
+ updateWithValue: func(o options, v string) (oo options, err error) {
+ o.bindHost, err = netip.ParseAddr(v)
- return []string{s}
-}
+ return o, err
+ },
+ updateNoValue: nil,
+ effect: nil,
+ serialize: func(o options) (val string, ok bool) {
+ if !o.bindHost.IsValid() {
+ return "", false
+ }
-func intSliceOrNil(i int) []string {
- if i == 0 {
- return nil
- }
-
- return []string{strconv.Itoa(i)}
-}
-
-func boolSliceOrNil(b bool) []string {
- if b {
- return []string{}
- }
-
- return nil
-}
-
-var args []arg
-
-var configArg = arg{
- "Path to the config file.",
- "config", "c",
- func(o options, v string) (options, error) { o.configFilename = v; return o, nil },
- nil,
- nil,
- func(o options) []string { return stringSliceOrNil(o.configFilename) },
-}
-
-var workDirArg = arg{
- "Path to the working directory.",
- "work-dir", "w",
- func(o options, v string) (options, error) { o.workDir = v; return o, nil }, nil, nil,
- func(o options) []string { return stringSliceOrNil(o.workDir) },
-}
-
-var hostArg = arg{
- "Host address to bind HTTP server on.",
- "host", "h",
- func(o options, v string) (options, error) { o.bindHost = net.ParseIP(v); return o, nil }, nil, nil,
- func(o options) []string { return ipSliceOrNil(o.bindHost) },
-}
-
-var portArg = arg{
- "Port to serve HTTP pages on.",
- "port", "p",
- func(o options, v string) (options, error) {
+ return o.bindHost.String(), true
+ },
+ description: "Host address to bind HTTP server on.",
+ longName: "host",
+ shortName: "h",
+}, {
+ updateWithValue: func(o options, v string) (options, error) {
var err error
var p int
minPort, maxPort := 0, 1<<16-1
@@ -131,108 +159,81 @@ var portArg = arg{
} else {
o.bindPort = p
}
- return o, err
- }, nil, nil,
- func(o options) []string { return intSliceOrNil(o.bindPort) },
-}
-var serviceArg = arg{
- "Service control action: status, install, uninstall, start, stop, restart, reload (configuration).",
- "service", "s",
- func(o options, v string) (options, error) {
+ return o, err
+ },
+ updateNoValue: nil,
+ effect: nil,
+ serialize: func(o options) (val string, ok bool) {
+ if o.bindPort == 0 {
+ return "", false
+ }
+
+ return strconv.Itoa(o.bindPort), true
+ },
+ description: "Port to serve HTTP pages on.",
+ longName: "port",
+ shortName: "p",
+}, {
+ updateWithValue: func(o options, v string) (options, error) {
o.serviceControlAction = v
return o, nil
- }, nil, nil,
- func(o options) []string { return stringSliceOrNil(o.serviceControlAction) },
-}
-
-var logfileArg = arg{
- "Path to log file. If empty: write to stdout; if 'syslog': write to system log.",
- "logfile", "l",
- func(o options, v string) (options, error) { o.logFile = v; return o, nil }, nil, nil,
- func(o options) []string { return stringSliceOrNil(o.logFile) },
-}
-
-var pidfileArg = arg{
- "Path to a file where PID is stored.",
- "pidfile", "",
- func(o options, v string) (options, error) { o.pidFile = v; return o, nil }, nil, nil,
- func(o options) []string { return stringSliceOrNil(o.pidFile) },
-}
-
-var checkConfigArg = arg{
- "Check configuration and exit.",
- "check-config", "",
- nil, func(o options) (options, error) { o.checkConfig = true; return o, nil }, nil,
- func(o options) []string { return boolSliceOrNil(o.checkConfig) },
-}
-
-var noCheckUpdateArg = arg{
- "Don't check for updates.",
- "no-check-update", "",
- nil, func(o options) (options, error) { o.disableUpdate = true; return o, nil }, nil,
- func(o options) []string { return boolSliceOrNil(o.disableUpdate) },
-}
-
-var disableMemoryOptimizationArg = arg{
- "Deprecated. Disable memory optimization.",
- "no-mem-optimization", "",
- nil, nil, func(_ options, _ string) (f effect, err error) {
+ },
+ updateNoValue: nil,
+ effect: nil,
+ serialize: func(o options) (val string, ok bool) {
+ return o.serviceControlAction, o.serviceControlAction != ""
+ },
+ description: `Service control action: status, install (as a service), ` +
+ `uninstall (as a service), start, stop, restart, reload (configuration).`,
+ longName: "service",
+ shortName: "s",
+}, {
+ updateWithValue: func(o options, v string) (options, error) { o.logFile = v; return o, nil },
+ updateNoValue: nil,
+ effect: nil,
+ serialize: func(o options) (val string, ok bool) { return o.logFile, o.logFile != "" },
+ description: `Path to log file. If empty, write to stdout; ` +
+ `if "syslog", write to system log.`,
+ longName: "logfile",
+ shortName: "l",
+}, {
+ updateWithValue: func(o options, v string) (options, error) { o.pidFile = v; return o, nil },
+ updateNoValue: nil,
+ effect: nil,
+ serialize: func(o options) (val string, ok bool) { return o.pidFile, o.pidFile != "" },
+ description: "Path to a file where PID is stored.",
+ longName: "pidfile",
+ shortName: "",
+}, {
+ updateWithValue: nil,
+ updateNoValue: func(o options) (options, error) { o.checkConfig = true; return o, nil },
+ effect: nil,
+ serialize: func(o options) (val string, ok bool) { return "", o.checkConfig },
+ description: "Check configuration and exit.",
+ longName: "check-config",
+ shortName: "",
+}, {
+ updateWithValue: nil,
+ updateNoValue: func(o options) (options, error) { o.disableUpdate = true; return o, nil },
+ effect: nil,
+ serialize: func(o options) (val string, ok bool) { return "", o.disableUpdate },
+ description: "Don't check for updates.",
+ longName: "no-check-update",
+ shortName: "",
+}, {
+ updateWithValue: nil,
+ updateNoValue: nil,
+ effect: func(_ options, _ string) (f effect, err error) {
log.Info("warning: using --no-mem-optimization flag has no effect and is deprecated")
return nil, nil
},
- func(o options) []string { return nil },
-}
-
-var verboseArg = arg{
- "Enable verbose output.",
- "verbose", "v",
- nil, func(o options) (options, error) { o.verbose = true; return o, nil }, nil,
- func(o options) []string { return boolSliceOrNil(o.verbose) },
-}
-
-var glinetArg = arg{
- "Run in GL-Inet compatibility mode.",
- "glinet", "",
- nil, func(o options) (options, error) { o.glinetMode = true; return o, nil }, nil,
- func(o options) []string { return boolSliceOrNil(o.glinetMode) },
-}
-
-var versionArg = arg{
- description: "Show the version and exit. Show more detailed version description with -v.",
- longName: "version",
- shortName: "",
- updateWithValue: nil,
- updateNoValue: nil,
- effect: func(o options, exec string) (effect, error) {
- return func() error {
- if o.verbose {
- fmt.Println(version.Verbose())
- } else {
- fmt.Println(version.Full())
- }
- os.Exit(0)
-
- return nil
- }, nil
- },
- serialize: func(o options) []string { return nil },
-}
-
-var helpArg = arg{
- "Print this help.",
- "help", "",
- nil, nil, func(o options, exec string) (effect, error) {
- return func() error { _ = printHelp(exec); os.Exit(64); return nil }, nil
- },
- func(o options) []string { return nil },
-}
-
-var noEtcHostsArg = arg{
- description: "Deprecated. Do not use the OS-provided hosts.",
- longName: "no-etc-hosts",
- shortName: "",
+ serialize: func(o options) (val string, ok bool) { return "", false },
+ description: "Deprecated. Disable memory optimization.",
+ longName: "no-mem-optimization",
+ shortName: "",
+}, {
updateWithValue: nil,
updateNoValue: func(o options) (options, error) { o.noEtcHosts = true; return o, nil },
effect: func(_ options, _ string) (f effect, err error) {
@@ -242,146 +243,216 @@ var noEtcHostsArg = arg{
return nil, nil
},
- serialize: func(o options) []string { return boolSliceOrNil(o.noEtcHosts) },
-}
-
-var localFrontendArg = arg{
- description: "Use local frontend directories.",
- longName: "local-frontend",
- shortName: "",
+ serialize: func(o options) (val string, ok bool) { return "", o.noEtcHosts },
+ description: "Deprecated. Do not use the OS-provided hosts.",
+ longName: "no-etc-hosts",
+ shortName: "",
+}, {
updateWithValue: nil,
updateNoValue: func(o options) (options, error) { o.localFrontend = true; return o, nil },
effect: nil,
- serialize: func(o options) []string { return boolSliceOrNil(o.localFrontend) },
-}
+ serialize: func(o options) (val string, ok bool) { return "", o.localFrontend },
+ description: "Use local frontend directories.",
+ longName: "local-frontend",
+ shortName: "",
+}, {
+ updateWithValue: nil,
+ updateNoValue: func(o options) (options, error) { o.verbose = true; return o, nil },
+ effect: nil,
+ serialize: func(o options) (val string, ok bool) { return "", o.verbose },
+ description: "Enable verbose output.",
+ longName: "verbose",
+ shortName: "v",
+}, {
+ updateWithValue: nil,
+ updateNoValue: func(o options) (options, error) { o.glinetMode = true; return o, nil },
+ effect: nil,
+ serialize: func(o options) (val string, ok bool) { return "", o.glinetMode },
+ description: "Run in GL-Inet compatibility mode.",
+ longName: "glinet",
+ shortName: "",
+}, {
+ updateWithValue: nil,
+ updateNoValue: nil,
+ effect: func(o options, exec string) (effect, error) {
+ return func() error {
+ if o.verbose {
+ fmt.Println(version.Verbose())
+ } else {
+ fmt.Println(version.Full())
+ }
-func init() {
- args = []arg{
- configArg,
- workDirArg,
- hostArg,
- portArg,
- serviceArg,
- logfileArg,
- pidfileArg,
- checkConfigArg,
- noCheckUpdateArg,
- disableMemoryOptimizationArg,
- noEtcHostsArg,
- localFrontendArg,
- verboseArg,
- glinetArg,
- versionArg,
- helpArg,
- }
-}
+ os.Exit(0)
-func getUsageLines(exec string, args []arg) []string {
- usage := []string{
- "Usage:",
- "",
- fmt.Sprintf("%s [options]", exec),
- "",
- "Options:",
- }
- for _, arg := range args {
+ return nil
+ }, nil
+ },
+ serialize: func(o options) (val string, ok bool) { return "", false },
+ description: "Show the version and exit. Show more detailed version description with -v.",
+ longName: "version",
+ shortName: "",
+}}
+
+// printHelp prints the entire help message. It exits with an error code if
+// there are any I/O errors.
+func printHelp(exec string) {
+ b := &strings.Builder{}
+
+ stringutil.WriteToBuilder(
+ b,
+ "Usage:\n\n",
+ fmt.Sprintf("%s [options]\n\n", exec),
+ "Options:\n",
+ )
+
+ var err error
+ for _, opt := range cmdLineOpts {
val := ""
- if arg.updateWithValue != nil {
+ if opt.updateWithValue != nil {
val = " VALUE"
}
- if arg.shortName != "" {
- usage = append(usage, fmt.Sprintf(" -%s, %-30s %s",
- arg.shortName,
- "--"+arg.longName+val,
- arg.description))
+
+ longDesc := opt.longName + val
+ if opt.shortName != "" {
+ _, err = fmt.Fprintf(b, " -%s, --%-28s %s\n", opt.shortName, longDesc, opt.description)
} else {
- usage = append(usage, fmt.Sprintf(" %-34s %s",
- "--"+arg.longName+val,
- arg.description))
+ _, err = fmt.Fprintf(b, " --%-32s %s\n", longDesc, opt.description)
+ }
+
+ if err != nil {
+ // The only error here can be from incorrect Fprintf usage, which is
+ // a programmer error.
+ panic(err)
}
}
- return usage
+
+ _, err = fmt.Print(b)
+ if err != nil {
+ // Exit immediately, since not being able to print out a help message
+ // essentially means that the I/O is very broken at the moment.
+ exitWithError()
+ }
}
-func printHelp(exec string) error {
- for _, line := range getUsageLines(exec, args) {
- _, err := fmt.Println(line)
+// parseCmdOpts parses the command-line arguments into options and effects.
+func parseCmdOpts(cmdName string, args []string) (o options, eff effect, err error) {
+ // Don't use range since the loop changes the loop variable.
+ argsLen := len(args)
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ isKnown := false
+ for _, opt := range cmdLineOpts {
+ isKnown = argMatches(opt, arg)
+ if !isKnown {
+ continue
+ }
+
+ if opt.updateWithValue != nil {
+ i++
+ if i >= argsLen {
+ return o, eff, fmt.Errorf("got %s without argument", arg)
+ }
+
+ o, err = opt.updateWithValue(o, args[i])
+ } else {
+ o, eff, err = updateOptsNoValue(o, eff, opt, cmdName)
+ }
+
+ if err != nil {
+ return o, eff, fmt.Errorf("applying option %s: %w", arg, err)
+ }
+
+ break
+ }
+
+ if !isKnown {
+ return o, eff, fmt.Errorf("unknown option %s", arg)
+ }
+ }
+
+ return o, eff, err
+}
+
+// argMatches returns true if arg matches command-line option opt.
+func argMatches(opt cmdLineOpt, arg string) (ok bool) {
+ if arg == "" || arg[0] != '-' {
+ return false
+ }
+
+ arg = arg[1:]
+ if arg == "" {
+ return false
+ }
+
+ return (opt.shortName != "" && arg == opt.shortName) ||
+ (arg[0] == '-' && arg[1:] == opt.longName)
+}
+
+// updateOptsNoValue sets values or effects from opt into o or prev.
+func updateOptsNoValue(
+ o options,
+ prev effect,
+ opt cmdLineOpt,
+ cmdName string,
+) (updated options, chained effect, err error) {
+ if opt.updateNoValue != nil {
+ o, err = opt.updateNoValue(o)
+ if err != nil {
+ return o, prev, err
+ }
+
+ return o, prev, nil
+ }
+
+ next, err := opt.effect(o, cmdName)
+ if err != nil {
+ return o, prev, err
+ }
+
+ chained = chainEffect(prev, next)
+
+ return o, chained, nil
+}
+
+// chainEffect chans the next effect after the prev one. If prev is nil, eff
+// only calls next. If next is nil, eff is prev; if prev is nil, eff is next.
+func chainEffect(prev, next effect) (eff effect) {
+ if prev == nil {
+ return next
+ } else if next == nil {
+ return prev
+ }
+
+ eff = func() (err error) {
+ err = prev()
if err != nil {
return err
}
+
+ return next()
}
- return nil
+
+ return eff
}
-func argMatches(a arg, v string) bool {
- return v == "--"+a.longName || (a.shortName != "" && v == "-"+a.shortName)
-}
-
-func parse(exec string, ss []string) (o options, f effect, err error) {
- for i := 0; i < len(ss); i++ {
- v := ss[i]
- knownParam := false
- for _, arg := range args {
- if argMatches(arg, v) {
- if arg.updateWithValue != nil {
- if i+1 >= len(ss) {
- return o, f, fmt.Errorf("got %s without argument", v)
- }
- i++
- o, err = arg.updateWithValue(o, ss[i])
- if err != nil {
- return
- }
- } else if arg.updateNoValue != nil {
- o, err = arg.updateNoValue(o)
- if err != nil {
- return
- }
- } else if arg.effect != nil {
- var eff effect
- eff, err = arg.effect(o, exec)
- if err != nil {
- return
- }
- if eff != nil {
- prevf := f
- f = func() (ferr error) {
- if prevf != nil {
- ferr = prevf()
- }
- if ferr == nil {
- ferr = eff()
- }
- return ferr
- }
- }
- }
- knownParam = true
- break
- }
+// optsToArgs converts command line options into a list of arguments.
+func optsToArgs(o options) (args []string) {
+ for _, opt := range cmdLineOpts {
+ val, ok := opt.serialize(o)
+ if !ok {
+ continue
}
- if !knownParam {
- return o, f, fmt.Errorf("unknown option %v", v)
+
+ if opt.shortName != "" {
+ args = append(args, "-"+opt.shortName)
+ } else {
+ args = append(args, "--"+opt.longName)
+ }
+
+ if val != "" {
+ args = append(args, val)
}
}
- return
-}
-
-func shortestFlag(a arg) string {
- if a.shortName != "" {
- return "-" + a.shortName
- }
- return "--" + a.longName
-}
-
-func serialize(o options) []string {
- ss := []string{}
- for _, arg := range args {
- s := arg.serialize(o)
- if s != nil {
- ss = append(ss, append([]string{shortestFlag(arg)}, s...)...)
- }
- }
- return ss
+ return args
}
diff --git a/internal/home/options_test.go b/internal/home/options_test.go
index 21972b0a..32b4243a 100644
--- a/internal/home/options_test.go
+++ b/internal/home/options_test.go
@@ -2,7 +2,7 @@ package home
import (
"fmt"
- "net"
+ "net/netip"
"testing"
"github.com/stretchr/testify/assert"
@@ -12,7 +12,7 @@ import (
func testParseOK(t *testing.T, ss ...string) options {
t.Helper()
- o, _, err := parse("", ss)
+ o, _, err := parseCmdOpts("", ss)
require.NoError(t, err)
return o
@@ -21,7 +21,7 @@ func testParseOK(t *testing.T, ss ...string) options {
func testParseErr(t *testing.T, descr string, ss ...string) {
t.Helper()
- _, _, err := parse("", ss)
+ _, _, err := parseCmdOpts("", ss)
require.Error(t, err)
}
@@ -38,11 +38,11 @@ func TestParseVerbose(t *testing.T) {
}
func TestParseConfigFilename(t *testing.T) {
- assert.Equal(t, "", testParseOK(t).configFilename, "empty is no config filename")
- assert.Equal(t, "path", testParseOK(t, "-c", "path").configFilename, "-c is config filename")
+ assert.Equal(t, "", testParseOK(t).confFilename, "empty is no config filename")
+ assert.Equal(t, "path", testParseOK(t, "-c", "path").confFilename, "-c is config filename")
testParseParamMissing(t, "-c")
- assert.Equal(t, "path", testParseOK(t, "--config", "path").configFilename, "--config is config filename")
+ assert.Equal(t, "path", testParseOK(t, "--config", "path").confFilename, "--config is config filename")
testParseParamMissing(t, "--config")
}
@@ -56,11 +56,13 @@ func TestParseWorkDir(t *testing.T) {
}
func TestParseBindHost(t *testing.T) {
- assert.Nil(t, testParseOK(t).bindHost, "empty is not host")
- assert.Equal(t, net.IPv4(1, 2, 3, 4), testParseOK(t, "-h", "1.2.3.4").bindHost, "-h is host")
+ wantAddr := netip.MustParseAddr("1.2.3.4")
+
+ assert.Zero(t, testParseOK(t).bindHost, "empty is not host")
+ assert.Equal(t, wantAddr, testParseOK(t, "-h", "1.2.3.4").bindHost, "-h is host")
testParseParamMissing(t, "-h")
- assert.Equal(t, net.IPv4(1, 2, 3, 4), testParseOK(t, "--host", "1.2.3.4").bindHost, "--host is host")
+ assert.Equal(t, wantAddr, testParseOK(t, "--host", "1.2.3.4").bindHost, "--host is host")
testParseParamMissing(t, "--host")
}
@@ -103,7 +105,7 @@ func TestParseDisableUpdate(t *testing.T) {
// TODO(e.burkov): Remove after v0.108.0.
func TestParseDisableMemoryOptimization(t *testing.T) {
- o, eff, err := parse("", []string{"--no-mem-optimization"})
+ o, eff, err := parseCmdOpts("", []string{"--no-mem-optimization"})
require.NoError(t, err)
assert.Nil(t, eff)
@@ -130,73 +132,73 @@ func TestParseUnknown(t *testing.T) {
testParseErr(t, "unknown dash", "-")
}
-func TestSerialize(t *testing.T) {
+func TestOptsToArgs(t *testing.T) {
testCases := []struct {
name string
+ args []string
opts options
- ss []string
}{{
name: "empty",
+ args: []string{},
opts: options{},
- ss: []string{},
}, {
name: "config_filename",
- opts: options{configFilename: "path"},
- ss: []string{"-c", "path"},
+ args: []string{"-c", "path"},
+ opts: options{confFilename: "path"},
}, {
name: "work_dir",
+ args: []string{"-w", "path"},
opts: options{workDir: "path"},
- ss: []string{"-w", "path"},
}, {
name: "bind_host",
- opts: options{bindHost: net.IP{1, 2, 3, 4}},
- ss: []string{"-h", "1.2.3.4"},
+ opts: options{bindHost: netip.MustParseAddr("1.2.3.4")},
+ args: []string{"-h", "1.2.3.4"},
}, {
name: "bind_port",
+ args: []string{"-p", "666"},
opts: options{bindPort: 666},
- ss: []string{"-p", "666"},
}, {
name: "log_file",
+ args: []string{"-l", "path"},
opts: options{logFile: "path"},
- ss: []string{"-l", "path"},
}, {
name: "pid_file",
+ args: []string{"--pidfile", "path"},
opts: options{pidFile: "path"},
- ss: []string{"--pidfile", "path"},
}, {
name: "disable_update",
+ args: []string{"--no-check-update"},
opts: options{disableUpdate: true},
- ss: []string{"--no-check-update"},
}, {
name: "control_action",
+ args: []string{"-s", "run"},
opts: options{serviceControlAction: "run"},
- ss: []string{"-s", "run"},
}, {
name: "glinet_mode",
+ args: []string{"--glinet"},
opts: options{glinetMode: true},
- ss: []string{"--glinet"},
}, {
name: "multiple",
- opts: options{
- serviceControlAction: "run",
- configFilename: "config",
- workDir: "work",
- pidFile: "pid",
- disableUpdate: true,
- },
- ss: []string{
+ args: []string{
"-c", "config",
"-w", "work",
"-s", "run",
"--pidfile", "pid",
"--no-check-update",
},
+ opts: options{
+ serviceControlAction: "run",
+ confFilename: "config",
+ workDir: "work",
+ pidFile: "pid",
+ disableUpdate: true,
+ },
}}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
- result := serialize(tc.opts)
- assert.ElementsMatch(t, tc.ss, result)
+ result := optsToArgs(tc.opts)
+ assert.ElementsMatch(t, tc.args, result)
})
}
}
diff --git a/internal/home/service.go b/internal/home/service.go
index e52f9799..3aece1f2 100644
--- a/internal/home/service.go
+++ b/internal/home/service.go
@@ -197,7 +197,7 @@ func handleServiceControlAction(opts options, clientBuildFS fs.FS) {
DisplayName: serviceDisplayName,
Description: serviceDescription,
WorkingDirectory: pwd,
- Arguments: serialize(runOpts),
+ Arguments: optsToArgs(runOpts),
}
configureService(svcConfig)
diff --git a/internal/home/tls.go b/internal/home/tls.go
index a5089bd8..55c58fb5 100644
--- a/internal/home/tls.go
+++ b/internal/home/tls.go
@@ -14,8 +14,6 @@ import (
"fmt"
"net/http"
"os"
- "path/filepath"
- "runtime"
"strings"
"sync"
"time"
@@ -28,216 +26,256 @@ import (
"github.com/google/go-cmp/cmp"
)
-var tlsWebHandlersRegistered = false
+// tlsManager contains the current configuration and state of AdGuard Home TLS
+// encryption.
+type tlsManager struct {
+ // status is the current status of the configuration. It is never nil.
+ status *tlsConfigStatus
-// TLSMod - TLS module object
-type TLSMod struct {
- certLastMod time.Time // last modification time of the certificate file
- status tlsConfigStatus
- confLock sync.Mutex
- conf tlsConfigSettings
+ // certLastMod is the last modification time of the certificate file.
+ certLastMod time.Time
+
+ confLock sync.Mutex
+ conf tlsConfigSettings
}
-// Create TLS module
-func tlsCreate(conf tlsConfigSettings) *TLSMod {
- t := &TLSMod{}
- t.conf = conf
- if t.conf.Enabled {
- if !t.load() {
- // Something is not valid - return an empty TLS config
- return &TLSMod{conf: tlsConfigSettings{
- Enabled: conf.Enabled,
- ServerName: conf.ServerName,
- PortHTTPS: conf.PortHTTPS,
- PortDNSOverTLS: conf.PortDNSOverTLS,
- PortDNSOverQUIC: conf.PortDNSOverQUIC,
- AllowUnencryptedDoH: conf.AllowUnencryptedDoH,
- }}
+// newTLSManager initializes the TLS configuration.
+func newTLSManager(conf tlsConfigSettings) (m *tlsManager, err error) {
+ m = &tlsManager{
+ status: &tlsConfigStatus{},
+ conf: conf,
+ }
+
+ if m.conf.Enabled {
+ err = m.load()
+ if err != nil {
+ return nil, err
}
- t.setCertFileTime()
+
+ m.setCertFileTime()
}
- return t
+
+ return m, nil
}
-func (t *TLSMod) load() bool {
- if !tlsLoadConfig(&t.conf, &t.status) {
- log.Error("failed to load TLS config: %s", t.status.WarningValidation)
- return false
+// load reloads the TLS configuration from files or data from the config file.
+func (m *tlsManager) load() (err error) {
+ err = loadTLSConf(&m.conf, m.status)
+ if err != nil {
+ return fmt.Errorf("loading config: %w", err)
}
- // validate current TLS config and update warnings (it could have been loaded from file)
- data := validateCertificates(string(t.conf.CertificateChainData), string(t.conf.PrivateKeyData), t.conf.ServerName)
- if !data.ValidPair {
- log.Error("failed to validate certificate: %s", data.WarningValidation)
- return false
- }
- t.status = data
- return true
-}
-
-// Close - close module
-func (t *TLSMod) Close() {
+ return nil
}
// WriteDiskConfig - write config
-func (t *TLSMod) WriteDiskConfig(conf *tlsConfigSettings) {
- t.confLock.Lock()
- *conf = t.conf
- t.confLock.Unlock()
+func (m *tlsManager) WriteDiskConfig(conf *tlsConfigSettings) {
+ m.confLock.Lock()
+ *conf = m.conf
+ m.confLock.Unlock()
}
-func (t *TLSMod) setCertFileTime() {
- if len(t.conf.CertificatePath) == 0 {
+// setCertFileTime sets t.certLastMod from the certificate. If there are
+// errors, setCertFileTime logs them.
+func (m *tlsManager) setCertFileTime() {
+ if len(m.conf.CertificatePath) == 0 {
return
}
- fi, err := os.Stat(t.conf.CertificatePath)
+
+ fi, err := os.Stat(m.conf.CertificatePath)
if err != nil {
- log.Error("TLS: %s", err)
+ log.Error("tls: looking up certificate path: %s", err)
+
return
}
- t.certLastMod = fi.ModTime().UTC()
+
+ m.certLastMod = fi.ModTime().UTC()
}
-// Start updates the configuration of TLSMod and starts it.
-func (t *TLSMod) Start() {
- if !tlsWebHandlersRegistered {
- tlsWebHandlersRegistered = true
- t.registerWebHandlers()
- }
+// start updates the configuration of t and starts it.
+func (m *tlsManager) start() {
+ m.registerWebHandlers()
- t.confLock.Lock()
- tlsConf := t.conf
- t.confLock.Unlock()
+ m.confLock.Lock()
+ tlsConf := m.conf
+ m.confLock.Unlock()
- // The background context is used because the TLSConfigChanged wraps
- // context with timeout on its own and shuts down the server, which
- // handles current request.
+ // The background context is used because the TLSConfigChanged wraps context
+ // with timeout on its own and shuts down the server, which handles current
+ // request.
Context.web.TLSConfigChanged(context.Background(), tlsConf)
}
-// Reload updates the configuration of TLSMod and restarts it.
-func (t *TLSMod) Reload() {
- t.confLock.Lock()
- tlsConf := t.conf
- t.confLock.Unlock()
+// reload updates the configuration and restarts t.
+func (m *tlsManager) reload() {
+ m.confLock.Lock()
+ tlsConf := m.conf
+ m.confLock.Unlock()
if !tlsConf.Enabled || len(tlsConf.CertificatePath) == 0 {
return
}
+
fi, err := os.Stat(tlsConf.CertificatePath)
if err != nil {
- log.Error("TLS: %s", err)
- return
- }
- if fi.ModTime().UTC().Equal(t.certLastMod) {
- log.Debug("TLS: certificate file isn't modified")
- return
- }
- log.Debug("TLS: certificate file is modified")
+ log.Error("tls: %s", err)
- t.confLock.Lock()
- r := t.load()
- t.confLock.Unlock()
- if !r {
return
}
- t.certLastMod = fi.ModTime().UTC()
+ if fi.ModTime().UTC().Equal(m.certLastMod) {
+ log.Debug("tls: certificate file isn't modified")
+
+ return
+ }
+
+ log.Debug("tls: certificate file is modified")
+
+ m.confLock.Lock()
+ err = m.load()
+ m.confLock.Unlock()
+ if err != nil {
+ log.Error("tls: reloading: %s", err)
+
+ return
+ }
+
+ m.certLastMod = fi.ModTime().UTC()
_ = reconfigureDNSServer()
- t.confLock.Lock()
- tlsConf = t.conf
- t.confLock.Unlock()
- // The background context is used because the TLSConfigChanged wraps
- // context with timeout on its own and shuts down the server, which
- // handles current request.
+ m.confLock.Lock()
+ tlsConf = m.conf
+ m.confLock.Unlock()
+
+ // The background context is used because the TLSConfigChanged wraps context
+ // with timeout on its own and shuts down the server, which handles current
+ // request.
Context.web.TLSConfigChanged(context.Background(), tlsConf)
}
-// Set certificate and private key data
-func tlsLoadConfig(tls *tlsConfigSettings, status *tlsConfigStatus) bool {
- tls.CertificateChainData = []byte(tls.CertificateChain)
- tls.PrivateKeyData = []byte(tls.PrivateKey)
-
- var err error
- if tls.CertificatePath != "" {
- if tls.CertificateChain != "" {
- status.WarningValidation = "certificate data and file can't be set together"
- return false
- }
- tls.CertificateChainData, err = os.ReadFile(tls.CertificatePath)
+// loadTLSConf loads and validates the TLS configuration. The returned error is
+// also set in status.WarningValidation.
+func loadTLSConf(tlsConf *tlsConfigSettings, status *tlsConfigStatus) (err error) {
+ defer func() {
if err != nil {
status.WarningValidation = err.Error()
- return false
}
+ }()
+
+ tlsConf.CertificateChainData = []byte(tlsConf.CertificateChain)
+ tlsConf.PrivateKeyData = []byte(tlsConf.PrivateKey)
+
+ if tlsConf.CertificatePath != "" {
+ if tlsConf.CertificateChain != "" {
+ return errors.Error("certificate data and file can't be set together")
+ }
+
+ tlsConf.CertificateChainData, err = os.ReadFile(tlsConf.CertificatePath)
+ if err != nil {
+ return fmt.Errorf("reading cert file: %w", err)
+ }
+
status.ValidCert = true
}
- if tls.PrivateKeyPath != "" {
- if tls.PrivateKey != "" {
- status.WarningValidation = "private key data and file can't be set together"
- return false
+ if tlsConf.PrivateKeyPath != "" {
+ if tlsConf.PrivateKey != "" {
+ return errors.Error("private key data and file can't be set together")
}
- tls.PrivateKeyData, err = os.ReadFile(tls.PrivateKeyPath)
+
+ tlsConf.PrivateKeyData, err = os.ReadFile(tlsConf.PrivateKeyPath)
if err != nil {
- status.WarningValidation = err.Error()
- return false
+ return fmt.Errorf("reading key file: %w", err)
}
+
status.ValidKey = true
}
- return true
+ err = validateCertificates(
+ status,
+ tlsConf.CertificateChainData,
+ tlsConf.PrivateKeyData,
+ tlsConf.ServerName,
+ )
+ if err != nil {
+ return fmt.Errorf("validating certificate pair: %w", err)
+ }
+
+ return nil
}
+// tlsConfigStatus contains the status of a certificate chain and key pair.
type tlsConfigStatus struct {
- ValidCert bool `json:"valid_cert"` // ValidCert is true if the specified certificates chain is a valid chain of X509 certificates
- ValidChain bool `json:"valid_chain"` // ValidChain is true if the specified certificates chain is verified and issued by a known CA
- Subject string `json:"subject,omitempty"` // Subject is the subject of the first certificate in the chain
- Issuer string `json:"issuer,omitempty"` // Issuer is the issuer of the first certificate in the chain
- NotBefore time.Time `json:"not_before,omitempty"` // NotBefore is the NotBefore field of the first certificate in the chain
- NotAfter time.Time `json:"not_after,omitempty"` // NotAfter is the NotAfter field of the first certificate in the chain
- DNSNames []string `json:"dns_names"` // DNSNames is the value of SubjectAltNames field of the first certificate in the chain
+ // Subject is the subject of the first certificate in the chain.
+ Subject string `json:"subject,omitempty"`
- // key status
- ValidKey bool `json:"valid_key"` // ValidKey is true if the key is a valid private key
- KeyType string `json:"key_type,omitempty"` // KeyType is one of RSA or ECDSA
+ // Issuer is the issuer of the first certificate in the chain.
+ Issuer string `json:"issuer,omitempty"`
- // is usable? set by validator
- ValidPair bool `json:"valid_pair"` // ValidPair is true if both certificate and private key are correct
+ // KeyType is the type of the private key.
+ KeyType string `json:"key_type,omitempty"`
- // warnings
- WarningValidation string `json:"warning_validation,omitempty"` // WarningValidation is a validation warning message with the issue description
+ // NotBefore is the NotBefore field of the first certificate in the chain.
+ NotBefore time.Time `json:"not_before,omitempty"`
+
+ // NotAfter is the NotAfter field of the first certificate in the chain.
+ NotAfter time.Time `json:"not_after,omitempty"`
+
+ // WarningValidation is a validation warning message with the issue
+ // description.
+ WarningValidation string `json:"warning_validation,omitempty"`
+
+ // DNSNames is the value of SubjectAltNames field of the first certificate
+ // in the chain.
+ DNSNames []string `json:"dns_names"`
+
+ // ValidCert is true if the specified certificate chain is a valid chain of
+ // X509 certificates.
+ ValidCert bool `json:"valid_cert"`
+
+ // ValidChain is true if the specified certificate chain is verified and
+ // issued by a known CA.
+ ValidChain bool `json:"valid_chain"`
+
+ // ValidKey is true if the key is a valid private key.
+ ValidKey bool `json:"valid_key"`
+
+ // ValidPair is true if both certificate and private key are correct for
+ // each other.
+ ValidPair bool `json:"valid_pair"`
}
-// field ordering is important -- yaml fields will mirror ordering from here
+// tlsConfig is the TLS configuration and status response.
type tlsConfig struct {
- tlsConfigStatus `json:",inline"`
+ *tlsConfigStatus `json:",inline"`
tlsConfigSettingsExt `json:",inline"`
}
-// tlsConfigSettingsExt is used to (un)marshal PrivateKeySaved to ensure that
-// clients don't send and receive previously saved private keys.
+// tlsConfigSettingsExt is used to (un)marshal the PrivateKeySaved field to
+// ensure that clients don't send and receive previously saved private keys.
type tlsConfigSettingsExt struct {
tlsConfigSettings `json:",inline"`
- // If private key saved as a string, we set this flag to true
- // and omit key from answer.
+
+ // PrivateKeySaved is true if the private key is saved as a string and omit
+ // key from answer.
PrivateKeySaved bool `yaml:"-" json:"private_key_saved,inline"`
}
-func (t *TLSMod) handleTLSStatus(w http.ResponseWriter, r *http.Request) {
- t.confLock.Lock()
+func (m *tlsManager) handleTLSStatus(w http.ResponseWriter, r *http.Request) {
+ m.confLock.Lock()
data := tlsConfig{
tlsConfigSettingsExt: tlsConfigSettingsExt{
- tlsConfigSettings: t.conf,
+ tlsConfigSettings: m.conf,
},
- tlsConfigStatus: t.status,
+ tlsConfigStatus: m.status,
}
- t.confLock.Unlock()
+ m.confLock.Unlock()
+
marshalTLS(w, r, data)
}
-func (t *TLSMod) handleTLSValidate(w http.ResponseWriter, r *http.Request) {
+func (m *tlsManager) handleTLSValidate(w http.ResponseWriter, r *http.Request) {
setts, err := unmarshalTLS(r)
if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "Failed to unmarshal TLS config: %s", err)
@@ -246,7 +284,7 @@ func (t *TLSMod) handleTLSValidate(w http.ResponseWriter, r *http.Request) {
}
if setts.PrivateKeySaved {
- setts.PrivateKey = t.conf.PrivateKey
+ setts.PrivateKey = m.conf.PrivateKey
}
if setts.Enabled {
@@ -278,75 +316,74 @@ func (t *TLSMod) handleTLSValidate(w http.ResponseWriter, r *http.Request) {
return
}
- status := tlsConfigStatus{}
- if tlsLoadConfig(&setts.tlsConfigSettings, &status) {
- status = validateCertificates(string(setts.CertificateChainData), string(setts.PrivateKeyData), setts.ServerName)
- }
-
- data := tlsConfig{
+ // Skip the error check, since we are only interested in the value of
+ // status.WarningValidation.
+ status := &tlsConfigStatus{}
+ _ = loadTLSConf(&setts.tlsConfigSettings, status)
+ resp := tlsConfig{
tlsConfigSettingsExt: setts,
tlsConfigStatus: status,
}
- marshalTLS(w, r, data)
+ marshalTLS(w, r, resp)
}
-func (t *TLSMod) setConfig(newConf tlsConfigSettings, status tlsConfigStatus) (restartHTTPS bool) {
- t.confLock.Lock()
- defer t.confLock.Unlock()
+func (m *tlsManager) setConfig(newConf tlsConfigSettings, status *tlsConfigStatus) (restartHTTPS bool) {
+ m.confLock.Lock()
+ defer m.confLock.Unlock()
// Reset the DNSCrypt data before comparing, since we currently do not
// accept these from the frontend.
//
// TODO(a.garipov): Define a custom comparer for dnsforward.TLSConfig.
- newConf.DNSCryptConfigFile = t.conf.DNSCryptConfigFile
- newConf.PortDNSCrypt = t.conf.PortDNSCrypt
- if !cmp.Equal(t.conf, newConf, cmp.AllowUnexported(dnsforward.TLSConfig{})) {
+ newConf.DNSCryptConfigFile = m.conf.DNSCryptConfigFile
+ newConf.PortDNSCrypt = m.conf.PortDNSCrypt
+ if !cmp.Equal(m.conf, newConf, cmp.AllowUnexported(dnsforward.TLSConfig{})) {
log.Info("tls config has changed, restarting https server")
restartHTTPS = true
} else {
- log.Info("tls config has not changed")
+ log.Info("tls: config has not changed")
}
// Note: don't do just `t.conf = data` because we must preserve all other members of t.conf
- t.conf.Enabled = newConf.Enabled
- t.conf.ServerName = newConf.ServerName
- t.conf.ForceHTTPS = newConf.ForceHTTPS
- t.conf.PortHTTPS = newConf.PortHTTPS
- t.conf.PortDNSOverTLS = newConf.PortDNSOverTLS
- t.conf.PortDNSOverQUIC = newConf.PortDNSOverQUIC
- t.conf.CertificateChain = newConf.CertificateChain
- t.conf.CertificatePath = newConf.CertificatePath
- t.conf.CertificateChainData = newConf.CertificateChainData
- t.conf.PrivateKey = newConf.PrivateKey
- t.conf.PrivateKeyPath = newConf.PrivateKeyPath
- t.conf.PrivateKeyData = newConf.PrivateKeyData
- t.status = status
+ m.conf.Enabled = newConf.Enabled
+ m.conf.ServerName = newConf.ServerName
+ m.conf.ForceHTTPS = newConf.ForceHTTPS
+ m.conf.PortHTTPS = newConf.PortHTTPS
+ m.conf.PortDNSOverTLS = newConf.PortDNSOverTLS
+ m.conf.PortDNSOverQUIC = newConf.PortDNSOverQUIC
+ m.conf.CertificateChain = newConf.CertificateChain
+ m.conf.CertificatePath = newConf.CertificatePath
+ m.conf.CertificateChainData = newConf.CertificateChainData
+ m.conf.PrivateKey = newConf.PrivateKey
+ m.conf.PrivateKeyPath = newConf.PrivateKeyPath
+ m.conf.PrivateKeyData = newConf.PrivateKeyData
+ m.status = status
return restartHTTPS
}
-func (t *TLSMod) handleTLSConfigure(w http.ResponseWriter, r *http.Request) {
- data, err := unmarshalTLS(r)
+func (m *tlsManager) handleTLSConfigure(w http.ResponseWriter, r *http.Request) {
+ req, err := unmarshalTLS(r)
if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "Failed to unmarshal TLS config: %s", err)
return
}
- if data.PrivateKeySaved {
- data.PrivateKey = t.conf.PrivateKey
+ if req.PrivateKeySaved {
+ req.PrivateKey = m.conf.PrivateKey
}
- if data.Enabled {
+ if req.Enabled {
err = validatePorts(
tcpPort(config.BindPort),
tcpPort(config.BetaBindPort),
- tcpPort(data.PortHTTPS),
- tcpPort(data.PortDNSOverTLS),
- tcpPort(data.PortDNSCrypt),
+ tcpPort(req.PortHTTPS),
+ tcpPort(req.PortDNSOverTLS),
+ tcpPort(req.PortDNSCrypt),
udpPort(config.DNS.Port),
- udpPort(data.PortDNSOverQUIC),
+ udpPort(req.PortDNSOverQUIC),
)
if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
@@ -356,33 +393,33 @@ func (t *TLSMod) handleTLSConfigure(w http.ResponseWriter, r *http.Request) {
}
// TODO(e.burkov): Investigate and perhaps check other ports.
- if !webCheckPortAvailable(data.PortHTTPS) {
+ if !webCheckPortAvailable(req.PortHTTPS) {
aghhttp.Error(
r,
w,
http.StatusBadRequest,
- "port %d is not available, cannot enable HTTPS on it",
- data.PortHTTPS,
+ "port %d is not available, cannot enable https on it",
+ req.PortHTTPS,
)
return
}
- status := tlsConfigStatus{}
- if !tlsLoadConfig(&data.tlsConfigSettings, &status) {
- data2 := tlsConfig{
- tlsConfigSettingsExt: data,
- tlsConfigStatus: t.status,
+ status := &tlsConfigStatus{}
+ err = loadTLSConf(&req.tlsConfigSettings, status)
+ if err != nil {
+ resp := tlsConfig{
+ tlsConfigSettingsExt: req,
+ tlsConfigStatus: status,
}
- marshalTLS(w, r, data2)
+
+ marshalTLS(w, r, resp)
return
}
- status = validateCertificates(string(data.CertificateChainData), string(data.PrivateKeyData), data.ServerName)
-
- restartHTTPS := t.setConfig(data.tlsConfigSettings, status)
- t.setCertFileTime()
+ restartHTTPS := m.setConfig(req.tlsConfigSettings, status)
+ m.setCertFileTime()
onConfigModified()
err = reconfigureDNSServer()
@@ -392,12 +429,12 @@ func (t *TLSMod) handleTLSConfigure(w http.ResponseWriter, r *http.Request) {
return
}
- data2 := tlsConfig{
- tlsConfigSettingsExt: data,
- tlsConfigStatus: t.status,
+ resp := tlsConfig{
+ tlsConfigSettingsExt: req,
+ tlsConfigStatus: m.status,
}
- marshalTLS(w, r, data2)
+ marshalTLS(w, r, resp)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
@@ -408,7 +445,7 @@ func (t *TLSMod) handleTLSConfigure(w http.ResponseWriter, r *http.Request) {
// same reason.
if restartHTTPS {
go func() {
- Context.web.TLSConfigChanged(context.Background(), data.tlsConfigSettings)
+ Context.web.TLSConfigChanged(context.Background(), req.tlsConfigSettings)
}()
}
}
@@ -445,89 +482,105 @@ func validatePorts(
return nil
}
-func verifyCertChain(data *tlsConfigStatus, certChain, serverName string) error {
- log.Tracef("TLS: got certificate: %d bytes", len(certChain))
+// validateCertChain validates the certificate chain and sets data in status.
+// The returned error is also set in status.WarningValidation.
+func validateCertChain(status *tlsConfigStatus, certChain []byte, serverName string) (err error) {
+ defer func() {
+ if err != nil {
+ status.WarningValidation = err.Error()
+ }
+ }()
- // now do a more extended validation
- var certs []*pem.Block // PEM-encoded certificates
+ log.Debug("tls: got certificate chain: %d bytes", len(certChain))
- pemblock := []byte(certChain)
+ var certs []*pem.Block
+ pemblock := certChain
for {
var decoded *pem.Block
decoded, pemblock = pem.Decode(pemblock)
if decoded == nil {
break
}
+
if decoded.Type == "CERTIFICATE" {
certs = append(certs, decoded)
}
}
- var parsedCerts []*x509.Certificate
-
- for _, cert := range certs {
- parsed, err := x509.ParseCertificate(cert.Bytes)
- if err != nil {
- data.WarningValidation = fmt.Sprintf("Failed to parse certificate: %s", err)
- return errors.Error(data.WarningValidation)
- }
- parsedCerts = append(parsedCerts, parsed)
+ parsedCerts, err := parsePEMCerts(certs)
+ if err != nil {
+ return err
}
- if len(parsedCerts) == 0 {
- data.WarningValidation = "You have specified an empty certificate"
- return errors.Error(data.WarningValidation)
- }
-
- data.ValidCert = true
-
- // spew.Dump(parsedCerts)
+ status.ValidCert = true
opts := x509.VerifyOptions{
DNSName: serverName,
Roots: Context.tlsRoots,
}
- log.Printf("number of certs - %d", len(parsedCerts))
- if len(parsedCerts) > 1 {
- // set up an intermediate
- pool := x509.NewCertPool()
- for _, cert := range parsedCerts[1:] {
- log.Printf("got an intermediate cert")
- pool.AddCert(cert)
- }
- opts.Intermediates = pool
+ log.Info("tls: number of certs: %d", len(parsedCerts))
+
+ pool := x509.NewCertPool()
+ for _, cert := range parsedCerts[1:] {
+ log.Info("tls: got an intermediate cert")
+ pool.AddCert(cert)
}
- // TODO: save it as a warning rather than error it out -- shouldn't be a big problem
+ opts.Intermediates = pool
+
mainCert := parsedCerts[0]
- _, err := mainCert.Verify(opts)
+ _, err = mainCert.Verify(opts)
if err != nil {
- // let self-signed certs through
- data.WarningValidation = fmt.Sprintf("Your certificate does not verify: %s", err)
+ // Let self-signed certs through and don't return this error.
+ status.WarningValidation = fmt.Sprintf("certificate does not verify: %s", err)
} else {
- data.ValidChain = true
+ status.ValidChain = true
}
- // spew.Dump(chains)
- // update status
if mainCert != nil {
- notAfter := mainCert.NotAfter
- data.Subject = mainCert.Subject.String()
- data.Issuer = mainCert.Issuer.String()
- data.NotAfter = notAfter
- data.NotBefore = mainCert.NotBefore
- data.DNSNames = mainCert.DNSNames
+ status.Subject = mainCert.Subject.String()
+ status.Issuer = mainCert.Issuer.String()
+ status.NotAfter = mainCert.NotAfter
+ status.NotBefore = mainCert.NotBefore
+ status.DNSNames = mainCert.DNSNames
}
return nil
}
-func validatePkey(data *tlsConfigStatus, pkey string) error {
- // now do a more extended validation
- var key *pem.Block // PEM-encoded certificates
+// parsePEMCerts parses multiple PEM-encoded certificates.
+func parsePEMCerts(certs []*pem.Block) (parsedCerts []*x509.Certificate, err error) {
+ for i, cert := range certs {
+ var parsed *x509.Certificate
+ parsed, err = x509.ParseCertificate(cert.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("parsing certificate at index %d: %w", i, err)
+ }
- // go through all pem blocks, but take first valid pem block and drop the rest
+ parsedCerts = append(parsedCerts, parsed)
+ }
+
+ if len(parsedCerts) == 0 {
+ return nil, errors.Error("empty certificate")
+ }
+
+ return parsedCerts, nil
+}
+
+// validatePKey validates the private key and sets data in status. The returned
+// error is also set in status.WarningValidation.
+func validatePKey(status *tlsConfigStatus, pkey []byte) (err error) {
+ defer func() {
+ if err != nil {
+ status.WarningValidation = err.Error()
+ }
+ }()
+
+ var key *pem.Block
+
+ // Go through all pem blocks, but take first valid pem block and drop the
+ // rest.
pemblock := []byte(pkey)
for {
var decoded *pem.Block
@@ -544,61 +597,77 @@ func validatePkey(data *tlsConfigStatus, pkey string) error {
}
if key == nil {
- data.WarningValidation = "No valid keys were found"
-
- return errors.Error(data.WarningValidation)
+ return errors.Error("no valid keys were found")
}
- // parse the decoded key
_, keyType, err := parsePrivateKey(key.Bytes)
if err != nil {
- data.WarningValidation = fmt.Sprintf("Failed to parse private key: %s", err)
-
- return errors.Error(data.WarningValidation)
- } else if keyType == keyTypeED25519 {
- data.WarningValidation = "ED25519 keys are not supported by browsers; " +
- "did you mean to use X25519 for key exchange?"
-
- return errors.Error(data.WarningValidation)
+ return fmt.Errorf("parsing private key: %w", err)
}
- data.ValidKey = true
- data.KeyType = keyType
+ if keyType == keyTypeED25519 {
+ return errors.Error(
+ "ED25519 keys are not supported by browsers; " +
+ "did you mean to use X25519 for key exchange?",
+ )
+ }
+
+ status.ValidKey = true
+ status.KeyType = keyType
return nil
}
// validateCertificates processes certificate data and its private key. All
-// parameters are optional. On error, validateCertificates returns a partially
-// set object with field WarningValidation containing error description.
-func validateCertificates(certChain, pkey, serverName string) tlsConfigStatus {
- var data tlsConfigStatus
-
- // check only public certificate separately from the key
- if certChain != "" {
- if verifyCertChain(&data, certChain, serverName) != nil {
- return data
+// parameters are optional. status must not be nil. The returned error is also
+// set in status.WarningValidation.
+func validateCertificates(
+ status *tlsConfigStatus,
+ certChain []byte,
+ pkey []byte,
+ serverName string,
+) (err error) {
+ defer func() {
+ // Capitalize the warning for the UI. Assume that warnings are all
+ // ASCII-only.
+ //
+ // TODO(a.garipov): Figure out a better way to do this. Perhaps a
+ // custom string or error type.
+ if w := status.WarningValidation; w != "" {
+ status.WarningValidation = strings.ToUpper(w[:1]) + w[1:]
}
- }
+ }()
- // validate private key (right now the only validation possible is just parsing it)
- if pkey != "" {
- if validatePkey(&data, pkey) != nil {
- return data
- }
- }
-
- // if both are set, validate both in unison
- if pkey != "" && certChain != "" {
- _, err := tls.X509KeyPair([]byte(certChain), []byte(pkey))
+ // Check only the public certificate separately from the key.
+ if len(certChain) > 0 {
+ err = validateCertChain(status, certChain, serverName)
if err != nil {
- data.WarningValidation = fmt.Sprintf("Invalid certificate or key: %s", err)
- return data
+ return err
}
- data.ValidPair = true
}
- return data
+ // Validate the private key by parsing it.
+ if len(pkey) > 0 {
+ err = validatePKey(status, pkey)
+ if err != nil {
+ return err
+ }
+ }
+
+ // If both are set, validate together.
+ if len(certChain) > 0 && len(pkey) > 0 {
+ _, err = tls.X509KeyPair(certChain, pkey)
+ if err != nil {
+ err = fmt.Errorf("certificate-key pair: %w", err)
+ status.WarningValidation = err.Error()
+
+ return err
+ }
+
+ status.ValidPair = true
+ }
+
+ return nil
}
// Key types.
@@ -693,52 +762,9 @@ func marshalTLS(w http.ResponseWriter, r *http.Request, data tlsConfig) {
_ = aghhttp.WriteJSONResponse(w, r, data)
}
-// registerWebHandlers registers HTTP handlers for TLS configuration
-func (t *TLSMod) registerWebHandlers() {
- httpRegister(http.MethodGet, "/control/tls/status", t.handleTLSStatus)
- httpRegister(http.MethodPost, "/control/tls/configure", t.handleTLSConfigure)
- httpRegister(http.MethodPost, "/control/tls/validate", t.handleTLSValidate)
-}
-
-// LoadSystemRootCAs tries to load root certificates from the operating system.
-// It returns nil in case nothing is found so that that Go.crypto will use it's
-// default algorithm to find system root CA list.
-//
-// See https://github.com/AdguardTeam/AdGuardHome/internal/issues/1311.
-func LoadSystemRootCAs() (roots *x509.CertPool) {
- // TODO(e.burkov): Use build tags instead.
- if runtime.GOOS != "linux" {
- return nil
- }
-
- // Directories with the system root certificates, which aren't supported
- // by Go.crypto.
- dirs := []string{
- // Entware.
- "/opt/etc/ssl/certs",
- }
- roots = x509.NewCertPool()
- for _, dir := range dirs {
- dirEnts, err := os.ReadDir(dir)
- if errors.Is(err, os.ErrNotExist) {
- continue
- } else if err != nil {
- log.Error("opening directory: %q: %s", dir, err)
- }
-
- var rootsAdded bool
- for _, de := range dirEnts {
- var certData []byte
- certData, err = os.ReadFile(filepath.Join(dir, de.Name()))
- if err == nil && roots.AppendCertsFromPEM(certData) {
- rootsAdded = true
- }
- }
-
- if rootsAdded {
- return roots
- }
- }
-
- return nil
+// registerWebHandlers registers HTTP handlers for TLS configuration.
+func (m *tlsManager) registerWebHandlers() {
+ httpRegister(http.MethodGet, "/control/tls/status", m.handleTLSStatus)
+ httpRegister(http.MethodPost, "/control/tls/configure", m.handleTLSConfigure)
+ httpRegister(http.MethodPost, "/control/tls/validate", m.handleTLSValidate)
}
diff --git a/internal/home/control_test.go b/internal/home/tls_internal_test.go
similarity index 58%
rename from internal/home/control_test.go
rename to internal/home/tls_internal_test.go
index 46f14a2a..b6e02f24 100644
--- a/internal/home/control_test.go
+++ b/internal/home/tls_internal_test.go
@@ -7,8 +7,7 @@ import (
"github.com/stretchr/testify/assert"
)
-const (
- CertificateChain = `-----BEGIN CERTIFICATE-----
+var testCertChainData = []byte(`-----BEGIN CERTIFICATE-----
MIICKzCCAZSgAwIBAgIJAMT9kPVJdM7LMA0GCSqGSIb3DQEBCwUAMC0xFDASBgNV
BAoMC0FkR3VhcmQgTHRkMRUwEwYDVQQDDAxBZEd1YXJkIEhvbWUwHhcNMTkwMjI3
MDkyNDIzWhcNNDYwNzE0MDkyNDIzWjAtMRQwEgYDVQQKDAtBZEd1YXJkIEx0ZDEV
@@ -21,8 +20,9 @@ eKO029jYd2AAZEQwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOBgQB8
LwlXfbakf7qkVTlCNXgoY7RaJ8rJdPgOZPoCTVToEhT6u/cb1c2qp8QB0dNExDna
b0Z+dnODTZqQOJo6z/wIXlcUrnR4cQVvytXt8lFn+26l6Y6EMI26twC/xWr+1swq
Muj4FeWHVDerquH4yMr1jsYLD3ci+kc5sbIX6TfVxQ==
------END CERTIFICATE-----`
- PrivateKey = `-----BEGIN PRIVATE KEY-----
+-----END CERTIFICATE-----`)
+
+var testPrivateKeyData = []byte(`-----BEGIN PRIVATE KEY-----
MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBALC/BSc8mI68tw5p
aYa7pjrySwWvXeetcFywOWHGVfLw9qiFWLdfESa3Y6tWMpZAXD9t1Xh9n211YUBV
FGSB4ZshnM/tgEPU6t787lJD4NsIIRp++MkJxdAitN4oUTqL0bdpIwezQ/CrYuBX
@@ -37,36 +37,43 @@ An/jMjZSMCxNl6UyFcqt5Et1EGVhuFECQQCZLXxaT+qcyHjlHJTMzuMgkz1QFbEp
O5EX70gpeGQMPDK0QSWpaazg956njJSDbNCFM4BccrdQbJu1cW4qOsfBAkAMgZuG
O88slmgTRHX4JGFmy3rrLiHNI2BbJSuJ++Yllz8beVzh6NfvuY+HKRCmPqoBPATU
kXS9jgARhhiWXJrk
------END PRIVATE KEY-----`
-)
+-----END PRIVATE KEY-----`)
func TestValidateCertificates(t *testing.T) {
t.Run("bad_certificate", func(t *testing.T) {
- data := validateCertificates("bad cert", "", "")
- assert.NotEmpty(t, data.WarningValidation)
- assert.False(t, data.ValidCert)
- assert.False(t, data.ValidChain)
+ status := &tlsConfigStatus{}
+ err := validateCertificates(status, []byte("bad cert"), nil, "")
+ assert.Error(t, err)
+ assert.NotEmpty(t, status.WarningValidation)
+ assert.False(t, status.ValidCert)
+ assert.False(t, status.ValidChain)
})
t.Run("bad_private_key", func(t *testing.T) {
- data := validateCertificates("", "bad priv key", "")
- assert.NotEmpty(t, data.WarningValidation)
- assert.False(t, data.ValidKey)
+ status := &tlsConfigStatus{}
+ err := validateCertificates(status, nil, []byte("bad priv key"), "")
+ assert.Error(t, err)
+ assert.NotEmpty(t, status.WarningValidation)
+ assert.False(t, status.ValidKey)
})
t.Run("valid", func(t *testing.T) {
- data := validateCertificates(CertificateChain, PrivateKey, "")
- notBefore, _ := time.Parse(time.RFC3339, "2019-02-27T09:24:23Z")
- notAfter, _ := time.Parse(time.RFC3339, "2046-07-14T09:24:23Z")
- assert.NotEmpty(t, data.WarningValidation)
- assert.True(t, data.ValidCert)
- assert.False(t, data.ValidChain)
- assert.True(t, data.ValidKey)
- assert.Equal(t, "RSA", data.KeyType)
- assert.Equal(t, "CN=AdGuard Home,O=AdGuard Ltd", data.Subject)
- assert.Equal(t, "CN=AdGuard Home,O=AdGuard Ltd", data.Issuer)
- assert.Equal(t, notBefore, data.NotBefore)
- assert.Equal(t, notAfter, data.NotAfter)
- assert.True(t, data.ValidPair)
+ status := &tlsConfigStatus{}
+ err := validateCertificates(status, testCertChainData, testPrivateKeyData, "")
+ assert.NoError(t, err)
+
+ notBefore := time.Date(2019, 2, 27, 9, 24, 23, 0, time.UTC)
+ notAfter := time.Date(2046, 7, 14, 9, 24, 23, 0, time.UTC)
+
+ assert.NotEmpty(t, status.WarningValidation)
+ assert.True(t, status.ValidCert)
+ assert.False(t, status.ValidChain)
+ assert.True(t, status.ValidKey)
+ assert.Equal(t, "RSA", status.KeyType)
+ assert.Equal(t, "CN=AdGuard Home,O=AdGuard Ltd", status.Subject)
+ assert.Equal(t, "CN=AdGuard Home,O=AdGuard Ltd", status.Issuer)
+ assert.Equal(t, notBefore, status.NotBefore)
+ assert.Equal(t, notAfter, status.NotAfter)
+ assert.True(t, status.ValidPair)
})
}
diff --git a/internal/home/web.go b/internal/home/web.go
index 9a94bead..86700357 100644
--- a/internal/home/web.go
+++ b/internal/home/web.go
@@ -4,8 +4,8 @@ import (
"context"
"crypto/tls"
"io/fs"
- "net"
"net/http"
+ "net/netip"
"sync"
"time"
@@ -39,7 +39,7 @@ type webConfig struct {
clientFS fs.FS
clientBetaFS fs.FS
- BindHost net.IP
+ BindHost netip.Addr
BindPort int
BetaBindPort int
PortHTTPS int
@@ -137,8 +137,11 @@ func newWeb(conf *webConfig) (w *Web) {
//
// TODO(a.garipov): Adapt for HTTP/3.
func webCheckPortAvailable(port int) (ok bool) {
- return Context.web.httpsServer.server != nil ||
- aghnet.CheckPort("tcp", config.BindHost, port) == nil
+ if Context.web.httpsServer.server != nil {
+ return true
+ }
+
+ return aghnet.CheckPort("tcp", netip.AddrPortFrom(config.BindHost, uint16(port))) == nil
}
// TLSConfigChanged updates the TLS configuration and restarts the HTTPS server
diff --git a/internal/next/agh/agh.go b/internal/next/agh/agh.go
new file mode 100644
index 00000000..52855524
--- /dev/null
+++ b/internal/next/agh/agh.go
@@ -0,0 +1,63 @@
+// Package agh contains common entities and interfaces of AdGuard Home.
+package agh
+
+import "context"
+
+// Service is the interface for API servers.
+//
+// TODO(a.garipov): Consider adding a context to Start.
+//
+// TODO(a.garipov): Consider adding a Wait method or making an extension
+// interface for that.
+type Service interface {
+ // Start starts the service. It does not block.
+ Start() (err error)
+
+ // Shutdown gracefully stops the service. ctx is used to determine
+ // a timeout before trying to stop the service less gracefully.
+ Shutdown(ctx context.Context) (err error)
+}
+
+// type check
+var _ Service = EmptyService{}
+
+// EmptyService is a [Service] that does nothing.
+//
+// TODO(a.garipov): Remove if unnecessary.
+type EmptyService struct{}
+
+// Start implements the [Service] interface for EmptyService.
+func (EmptyService) Start() (err error) { return nil }
+
+// Shutdown implements the [Service] interface for EmptyService.
+func (EmptyService) Shutdown(_ context.Context) (err error) { return nil }
+
+// ServiceWithConfig is an extension of the [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 {
+ Service
+
+ Config() (c ConfigType)
+}
+
+// type check
+var _ ServiceWithConfig[struct{}] = (*EmptyServiceWithConfig[struct{}])(nil)
+
+// EmptyServiceWithConfig is a ServiceWithConfig that does nothing. Its Config
+// method returns Conf.
+//
+// TODO(a.garipov): Remove if unnecessary.
+type EmptyServiceWithConfig[ConfigType any] struct {
+ EmptyService
+
+ Conf ConfigType
+}
+
+// Config implements the [ServiceWithConfig] interface for
+// *EmptyServiceWithConfig.
+func (s *EmptyServiceWithConfig[ConfigType]) Config() (conf ConfigType) {
+ return s.Conf
+}
diff --git a/internal/v1/cmd/cmd.go b/internal/next/cmd/cmd.go
similarity index 69%
rename from internal/v1/cmd/cmd.go
rename to internal/next/cmd/cmd.go
index 2f61509b..d2cc9c80 100644
--- a/internal/v1/cmd/cmd.go
+++ b/internal/next/cmd/cmd.go
@@ -8,39 +8,49 @@ import (
"context"
"io/fs"
"math/rand"
- "net/netip"
+ "os"
"time"
- "github.com/AdguardTeam/AdGuardHome/internal/v1/websvc"
+ "github.com/AdguardTeam/AdGuardHome/internal/next/configmgr"
+ "github.com/AdguardTeam/AdGuardHome/internal/version"
"github.com/AdguardTeam/golibs/log"
)
// Main is the entry point of application.
func Main(clientBuildFS fs.FS) {
- // # Initial Configuration
+ // Initial Configuration
start := time.Now()
rand.Seed(start.UnixNano())
// TODO(a.garipov): Set up logging.
- // # Web Service
+ log.Info("starting adguard home, version %s, pid %d", version.Version(), os.Getpid())
+
+ // Web Service
// TODO(a.garipov): Use in the Web service.
_ = clientBuildFS
- // TODO(a.garipov): Make configurable.
- web := websvc.New(&websvc.Config{
- Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:3001")},
- Start: start,
- Timeout: 60 * time.Second,
- })
+ // TODO(a.garipov): Set up configuration file name.
+ const confFile = "AdGuardHome.1.yaml"
- err := web.Start()
+ confMgr, err := configmgr.New(confFile, start)
+ fatalOnError(err)
+
+ web := confMgr.Web()
+ err = web.Start()
+ fatalOnError(err)
+
+ dns := confMgr.DNS()
+ err = dns.Start()
fatalOnError(err)
sigHdlr := newSignalHandler(
+ confFile,
+ start,
web,
+ dns,
)
go sigHdlr.handle()
diff --git a/internal/next/cmd/signal.go b/internal/next/cmd/signal.go
new file mode 100644
index 00000000..640d090b
--- /dev/null
+++ b/internal/next/cmd/signal.go
@@ -0,0 +1,118 @@
+package cmd
+
+import (
+ "os"
+ "time"
+
+ "github.com/AdguardTeam/AdGuardHome/internal/aghos"
+ "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
+ "github.com/AdguardTeam/AdGuardHome/internal/next/configmgr"
+ "github.com/AdguardTeam/golibs/log"
+)
+
+// signalHandler processes incoming signals and shuts services down.
+type signalHandler struct {
+ // signal is the channel to which OS signals are sent.
+ signal chan os.Signal
+
+ // confFile is the path to the configuration file.
+ confFile string
+
+ // start is the time at which AdGuard Home has been started.
+ start time.Time
+
+ // services are the services that are shut down before application exiting.
+ services []agh.Service
+}
+
+// handle processes OS signals.
+func (h *signalHandler) handle() {
+ defer log.OnPanic("signalHandler.handle")
+
+ for sig := range h.signal {
+ log.Info("sighdlr: received signal %q", sig)
+
+ if aghos.IsReconfigureSignal(sig) {
+ h.reconfigure()
+ } else if aghos.IsShutdownSignal(sig) {
+ status := h.shutdown()
+ log.Info("sighdlr: exiting with status %d", status)
+
+ os.Exit(status)
+ }
+ }
+}
+
+// reconfigure rereads the configuration file and updates and restarts services.
+func (h *signalHandler) reconfigure() {
+ log.Info("sighdlr: reconfiguring adguard home")
+
+ status := h.shutdown()
+ if status != statusSuccess {
+ log.Info("sighdlr: reconfiruging: exiting with status %d", status)
+
+ os.Exit(status)
+ }
+
+ // TODO(a.garipov): This is a very rough way to do it. Some services can be
+ // reconfigured without the full shutdown, and the error handling is
+ // currently not the best.
+
+ confMgr, err := configmgr.New(h.confFile, h.start)
+ fatalOnError(err)
+
+ web := confMgr.Web()
+ err = web.Start()
+ fatalOnError(err)
+
+ dns := confMgr.DNS()
+ err = dns.Start()
+ fatalOnError(err)
+
+ h.services = []agh.Service{
+ dns,
+ web,
+ }
+
+ log.Info("sighdlr: successfully reconfigured adguard home")
+}
+
+// Exit status constants.
+const (
+ statusSuccess = 0
+ statusError = 1
+)
+
+// shutdown gracefully shuts down all services.
+func (h *signalHandler) shutdown() (status int) {
+ ctx, cancel := ctxWithDefaultTimeout()
+ defer cancel()
+
+ status = statusSuccess
+
+ log.Info("sighdlr: shutting down services")
+ for i, service := range h.services {
+ err := service.Shutdown(ctx)
+ if err != nil {
+ log.Error("sighdlr: shutting down service at index %d: %s", i, err)
+ status = statusError
+ }
+ }
+
+ return status
+}
+
+// newSignalHandler returns a new signalHandler that shuts down svcs.
+func newSignalHandler(confFile string, start time.Time, svcs ...agh.Service) (h *signalHandler) {
+ h = &signalHandler{
+ signal: make(chan os.Signal, 1),
+ confFile: confFile,
+ start: start,
+ services: svcs,
+ }
+
+ aghos.NotifyShutdownSignal(h.signal)
+ aghos.NotifyReconfigureSignal(h.signal)
+
+ return h
+}
diff --git a/internal/next/configmgr/config.go b/internal/next/configmgr/config.go
new file mode 100644
index 00000000..d11d8c1a
--- /dev/null
+++ b/internal/next/configmgr/config.go
@@ -0,0 +1,40 @@
+package configmgr
+
+import (
+ "net/netip"
+
+ "github.com/AdguardTeam/golibs/timeutil"
+)
+
+// Configuration Structures
+
+// config is the top-level on-disk configuration structure.
+type config struct {
+ DNS *dnsConfig `yaml:"dns"`
+ HTTP *httpConfig `yaml:"http"`
+ // TODO(a.garipov): Use.
+ SchemaVersion int `yaml:"schema_version"`
+ // TODO(a.garipov): Use.
+ DebugPprof bool `yaml:"debug_pprof"`
+ Verbose bool `yaml:"verbose"`
+}
+
+// dnsConfig is the on-disk DNS configuration.
+//
+// TODO(a.garipov): Validate.
+type dnsConfig struct {
+ Addresses []netip.AddrPort `yaml:"addresses"`
+ BootstrapDNS []string `yaml:"bootstrap_dns"`
+ UpstreamDNS []string `yaml:"upstream_dns"`
+ UpstreamTimeout timeutil.Duration `yaml:"upstream_timeout"`
+}
+
+// httpConfig is the on-disk web API configuration.
+//
+// TODO(a.garipov): Validate.
+type httpConfig struct {
+ Addresses []netip.AddrPort `yaml:"addresses"`
+ SecureAddresses []netip.AddrPort `yaml:"secure_addresses"`
+ Timeout timeutil.Duration `yaml:"timeout"`
+ ForceHTTPS bool `yaml:"force_https"`
+}
diff --git a/internal/next/configmgr/configmgr.go b/internal/next/configmgr/configmgr.go
new file mode 100644
index 00000000..5b042274
--- /dev/null
+++ b/internal/next/configmgr/configmgr.go
@@ -0,0 +1,205 @@
+// Package configmgr defines the AdGuard Home on-disk configuration entities and
+// configuration manager.
+package configmgr
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
+ "github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc"
+ "github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
+ "github.com/AdguardTeam/golibs/errors"
+ "github.com/AdguardTeam/golibs/log"
+ "gopkg.in/yaml.v3"
+)
+
+// Configuration Manager
+
+// Manager handles full and partial changes in the configuration, persisting
+// them to disk if necessary.
+type Manager struct {
+ // updMu makes sure that at most one reconfiguration is performed at a time.
+ // updMu protects all fields below.
+ updMu *sync.RWMutex
+
+ // dns is the DNS service.
+ dns *dnssvc.Service
+
+ // Web is the Web API service.
+ web *websvc.Service
+
+ // current is the current configuration.
+ current *config
+
+ // fileName is the name of the configuration file.
+ fileName string
+}
+
+// New creates a new *Manager that persists changes to the file pointed to by
+// fileName. It reads the configuration file and populates the service fields.
+// start is the startup time of AdGuard Home.
+func New(fileName string, start time.Time) (m *Manager, err error) {
+ defer func() { err = errors.Annotate(err, "reading config") }()
+
+ conf := &config{}
+ f, err := os.Open(fileName)
+ if err != nil {
+ // Don't wrap the error, because it's informative enough as is.
+ return nil, err
+ }
+ defer func() { err = errors.WithDeferred(err, f.Close()) }()
+
+ err = yaml.NewDecoder(f).Decode(conf)
+ if err != nil {
+ // Don't wrap the error, because it's informative enough as is.
+ return nil, err
+ }
+
+ // TODO(a.garipov): Move into a separate function and add other logging
+ // settings.
+ if conf.Verbose {
+ log.SetLevel(log.DEBUG)
+ }
+
+ // TODO(a.garipov): Validate the configuration structure. Return an error
+ // if it's incorrect.
+
+ m = &Manager{
+ updMu: &sync.RWMutex{},
+ current: conf,
+ fileName: fileName,
+ }
+
+ // TODO(a.garipov): Get the context with the timeout from the arguments?
+ const assemblyTimeout = 5 * time.Second
+ ctx, cancel := context.WithTimeout(context.Background(), assemblyTimeout)
+ defer cancel()
+
+ err = m.assemble(ctx, conf, start)
+ if err != nil {
+ // Don't wrap the error, because it's informative enough as is.
+ return nil, err
+ }
+
+ return m, nil
+}
+
+// assemble creates all services and puts them into the corresponding fields.
+// The fields of conf must not be modified after calling assemble.
+func (m *Manager) assemble(ctx context.Context, conf *config, start time.Time) (err error) {
+ dnsConf := &dnssvc.Config{
+ Addresses: conf.DNS.Addresses,
+ BootstrapServers: conf.DNS.BootstrapDNS,
+ UpstreamServers: conf.DNS.UpstreamDNS,
+ UpstreamTimeout: conf.DNS.UpstreamTimeout.Duration,
+ }
+ err = m.updateDNS(ctx, dnsConf)
+ if err != nil {
+ return fmt.Errorf("assembling dnssvc: %w", err)
+ }
+
+ webSvcConf := &websvc.Config{
+ ConfigManager: m,
+ // TODO(a.garipov): Fill from config file.
+ TLS: nil,
+ Start: start,
+ Addresses: conf.HTTP.Addresses,
+ SecureAddresses: conf.HTTP.SecureAddresses,
+ Timeout: conf.HTTP.Timeout.Duration,
+ ForceHTTPS: conf.HTTP.ForceHTTPS,
+ }
+
+ err = m.updateWeb(ctx, webSvcConf)
+ if err != nil {
+ return fmt.Errorf("assembling websvc: %w", err)
+ }
+
+ return nil
+}
+
+// DNS returns the current DNS service. It is safe for concurrent use.
+func (m *Manager) DNS() (dns agh.ServiceWithConfig[*dnssvc.Config]) {
+ m.updMu.RLock()
+ defer m.updMu.RUnlock()
+
+ return m.dns
+}
+
+// UpdateDNS implements the [websvc.ConfigManager] interface for *Manager. The
+// fields of c must not be modified after calling UpdateDNS.
+func (m *Manager) UpdateDNS(ctx context.Context, c *dnssvc.Config) (err error) {
+ m.updMu.Lock()
+ defer m.updMu.Unlock()
+
+ // TODO(a.garipov): Update and write the configuration file. Return an
+ // error if something went wrong.
+
+ err = m.updateDNS(ctx, c)
+ if err != nil {
+ return fmt.Errorf("reassembling dnssvc: %w", err)
+ }
+
+ return nil
+}
+
+// updateDNS recreates the DNS service. m.updMu is expected to be locked.
+func (m *Manager) updateDNS(ctx context.Context, c *dnssvc.Config) (err error) {
+ if prev := m.dns; prev != nil {
+ err = prev.Shutdown(ctx)
+ if err != nil {
+ return fmt.Errorf("shutting down dns svc: %w", err)
+ }
+ }
+
+ svc, err := dnssvc.New(c)
+ if err != nil {
+ return fmt.Errorf("creating dns svc: %w", err)
+ }
+
+ m.dns = svc
+
+ return nil
+}
+
+// Web returns the current web service. It is safe for concurrent use.
+func (m *Manager) Web() (web agh.ServiceWithConfig[*websvc.Config]) {
+ m.updMu.RLock()
+ defer m.updMu.RUnlock()
+
+ return m.web
+}
+
+// UpdateWeb implements the [websvc.ConfigManager] interface for *Manager. The
+// fields of c must not be modified after calling UpdateWeb.
+func (m *Manager) UpdateWeb(ctx context.Context, c *websvc.Config) (err error) {
+ m.updMu.Lock()
+ defer m.updMu.Unlock()
+
+ // TODO(a.garipov): Update and write the configuration file. Return an
+ // error if something went wrong.
+
+ err = m.updateWeb(ctx, c)
+ if err != nil {
+ return fmt.Errorf("reassembling websvc: %w", err)
+ }
+
+ return nil
+}
+
+// updateWeb recreates the web service. m.upd is expected to be locked.
+func (m *Manager) updateWeb(ctx context.Context, c *websvc.Config) (err error) {
+ if prev := m.web; prev != nil {
+ err = prev.Shutdown(ctx)
+ if err != nil {
+ return fmt.Errorf("shutting down web svc: %w", err)
+ }
+ }
+
+ m.web = websvc.New(c)
+
+ return nil
+}
diff --git a/internal/v1/dnssvc/dnssvc.go b/internal/next/dnssvc/dnssvc.go
similarity index 77%
rename from internal/v1/dnssvc/dnssvc.go
rename to internal/next/dnssvc/dnssvc.go
index ffe5b080..f25fa294 100644
--- a/internal/v1/dnssvc/dnssvc.go
+++ b/internal/next/dnssvc/dnssvc.go
@@ -9,9 +9,10 @@ import (
"fmt"
"net"
"net/netip"
+ "sync/atomic"
"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
// and replacement of module dnsproxy.
"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
// [agh.Service] that does nothing.
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
bootstraps []string
upstreams []string
@@ -160,6 +169,17 @@ func (svc *Service) Start() (err error) {
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()
}
@@ -173,13 +193,27 @@ func (svc *Service) Shutdown(ctx context.Context) (err error) {
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) {
// TODO(a.garipov): Do we need to get the TCP addresses separately?
- udpAddrs := svc.proxy.Addrs(proxy.ProtoUDP)
- addrs := make([]netip.AddrPort, len(udpAddrs))
- for i, a := range udpAddrs {
- addrs[i] = a.(*net.UDPAddr).AddrPort()
+
+ var addrs []netip.AddrPort
+ if atomic.LoadUint64(&svc.running) == 1 {
+ 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{
diff --git a/internal/v1/dnssvc/dnssvc_test.go b/internal/next/dnssvc/dnssvc_test.go
similarity index 97%
rename from internal/v1/dnssvc/dnssvc_test.go
rename to internal/next/dnssvc/dnssvc_test.go
index 5bc3b562..8205897c 100644
--- a/internal/v1/dnssvc/dnssvc_test.go
+++ b/internal/next/dnssvc/dnssvc_test.go
@@ -7,7 +7,7 @@ import (
"time"
"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/miekg/dns"
"github.com/stretchr/testify/assert"
diff --git a/internal/next/websvc/dns.go b/internal/next/websvc/dns.go
new file mode 100644
index 00000000..8846813d
--- /dev/null
+++ b/internal/next/websvc/dns.go
@@ -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),
+ })
+}
diff --git a/internal/next/websvc/dns_test.go b/internal/next/websvc/dns_test.go
new file mode 100644
index 00000000..d0efec87
--- /dev/null
+++ b/internal/next/websvc/dns_test.go
@@ -0,0 +1,69 @@
+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/agh"
+ "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 agh.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)
+}
diff --git a/internal/next/websvc/http.go b/internal/next/websvc/http.go
new file mode 100644
index 00000000..c6107cd0
--- /dev/null
+++ b/internal/next/websvc/http.go
@@ -0,0 +1,110 @@
+package websvc
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/netip"
+ "time"
+
+ "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
+ "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 agh.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)
+ }
+ }()
+}
diff --git a/internal/next/websvc/http_test.go b/internal/next/websvc/http_test.go
new file mode 100644
index 00000000..d79be735
--- /dev/null
+++ b/internal/next/websvc/http_test.go
@@ -0,0 +1,63 @@
+package websvc_test
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "net/http"
+ "net/netip"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
+ "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 agh.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)
+}
diff --git a/internal/next/websvc/json.go b/internal/next/websvc/json.go
new file mode 100644
index 00000000..fa2010a8
--- /dev/null
+++ b/internal/next/websvc/json.go
@@ -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)
+}
diff --git a/internal/next/websvc/json_test.go b/internal/next/websvc/json_test.go
new file mode 100644
index 00000000..90874958
--- /dev/null
+++ b/internal/next/websvc/json_test.go
@@ -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)
+ })
+}
diff --git a/internal/v1/websvc/middleware.go b/internal/next/websvc/middleware.go
similarity index 100%
rename from internal/v1/websvc/middleware.go
rename to internal/next/websvc/middleware.go
diff --git a/internal/next/websvc/path.go b/internal/next/websvc/path.go
new file mode 100644
index 00000000..e38a1d60
--- /dev/null
+++ b/internal/next/websvc/path.go
@@ -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"
+)
diff --git a/internal/next/websvc/settings.go b/internal/next/websvc/settings.go
new file mode 100644
index 00000000..b6c5a80a
--- /dev/null
+++ b/internal/next/websvc/settings.go
@@ -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,
+ },
+ })
+}
diff --git a/internal/next/websvc/settings_test.go b/internal/next/websvc/settings_test.go
new file mode 100644
index 00000000..3dfc63fc
--- /dev/null
+++ b/internal/next/websvc/settings_test.go
@@ -0,0 +1,75 @@
+package websvc_test
+
+import (
+ "crypto/tls"
+ "encoding/json"
+ "net/http"
+ "net/netip"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
+ "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 agh.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 agh.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)
+}
diff --git a/internal/v1/websvc/system.go b/internal/next/websvc/system.go
similarity index 87%
rename from internal/v1/websvc/system.go
rename to internal/next/websvc/system.go
index 47d0c63c..fbf60fe4 100644
--- a/internal/v1/websvc/system.go
+++ b/internal/next/websvc/system.go
@@ -16,20 +16,20 @@ type RespGetV1SystemInfo struct {
Channel string `json:"channel"`
OS string `json:"os"`
NewVersion string `json:"new_version,omitempty"`
- Start jsonTime `json:"start"`
+ Start JSONTime `json:"start"`
Version string `json:"version"`
}
// handleGetV1SystemInfo is the handler for the GET /api/v1/system/info HTTP
// API.
func (svc *Service) handleGetV1SystemInfo(w http.ResponseWriter, r *http.Request) {
- writeJSONResponse(w, r, &RespGetV1SystemInfo{
+ writeJSONOKResponse(w, r, &RespGetV1SystemInfo{
Arch: runtime.GOARCH,
Channel: version.Channel(),
OS: runtime.GOOS,
// TODO(a.garipov): Fill this when we have an updater.
NewVersion: "",
- Start: jsonTime(svc.start),
+ Start: JSONTime(svc.start),
Version: version.Version(),
})
}
diff --git a/internal/v1/websvc/system_test.go b/internal/next/websvc/system_test.go
similarity index 82%
rename from internal/v1/websvc/system_test.go
rename to internal/next/websvc/system_test.go
index 49579ca5..acbdcba2 100644
--- a/internal/v1/websvc/system_test.go
+++ b/internal/next/websvc/system_test.go
@@ -8,16 +8,17 @@ import (
"testing"
"time"
- "github.com/AdguardTeam/AdGuardHome/internal/v1/websvc"
+ "github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestService_handleGetV1SystemInfo(t *testing.T) {
- _, addr := newTestServer(t)
+ confMgr := newConfigManager()
+ _, addr := newTestServer(t, confMgr)
u := &url.URL{
Scheme: "http",
- Host: addr,
+ Host: addr.String(),
Path: websvc.PathV1SystemInfo,
}
diff --git a/internal/next/websvc/waitlistener.go b/internal/next/websvc/waitlistener.go
new file mode 100644
index 00000000..8ab56269
--- /dev/null
+++ b/internal/next/websvc/waitlistener.go
@@ -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()
+}
diff --git a/internal/next/websvc/waitlistener_internal_test.go b/internal/next/websvc/waitlistener_internal_test.go
new file mode 100644
index 00000000..e151341b
--- /dev/null
+++ b/internal/next/websvc/waitlistener_internal_test.go
@@ -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))
+}
diff --git a/internal/v1/websvc/websvc.go b/internal/next/websvc/websvc.go
similarity index 54%
rename from internal/v1/websvc/websvc.go
rename to internal/next/websvc/websvc.go
index bbaac005..05422889 100644
--- a/internal/v1/websvc/websvc.go
+++ b/internal/next/websvc/websvc.go
@@ -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.
package websvc
@@ -14,18 +17,35 @@ import (
"sync"
"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/log"
httptreemux "github.com/dimfeld/httptreemux/v5"
)
+// ConfigManager is the configuration manager interface.
+type ConfigManager interface {
+ DNS() (svc agh.ServiceWithConfig[*dnssvc.Config])
+ Web() (svc agh.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.
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,
// SecureAddresses must not be empty.
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 []netip.AddrPort
@@ -33,40 +53,48 @@ type Config struct {
// SecureAddresses is not empty, TLS must not be nil.
SecureAddresses []netip.AddrPort
- // Start is the time of start of AdGuard Home.
- Start time.Time
-
// Timeout is the timeout for all server operations.
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
// [agh.Service] that does nothing.
type Service struct {
- tls *tls.Config
- servers []*http.Server
- start time.Time
- timeout time.Duration
+ confMgr ConfigManager
+ tls *tls.Config
+ start time.Time
+ servers []*http.Server
+ timeout time.Duration
+ forceHTTPS bool
}
// 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) {
if c == nil {
return nil
}
svc = &Service{
- tls: c.TLS,
- start: c.Start,
- timeout: c.Timeout,
+ confMgr: c.ConfigManager,
+ tls: c.TLS,
+ start: c.Start,
+ timeout: c.Timeout,
+ forceHTTPS: c.ForceHTTPS,
}
mux := newMux(svc)
for _, a := range c.Addresses {
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{
Addr: addr,
Handler: mux,
@@ -111,6 +139,21 @@ func newMux(svc *Service) (mux *httptreemux.ContextMux) {
method: http.MethodGet,
path: PathHealthCheck,
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,
method: http.MethodGet,
@@ -119,29 +162,41 @@ func newMux(svc *Service) (mux *httptreemux.ContextMux) {
}}
for _, r := range routes {
- var h http.HandlerFunc
if r.isJSON {
- // TODO(a.garipov): Consider using httptreemux's MiddlewareFunc.
- h = jsonMw(r.handler)
+ mux.Handle(r.method, r.path, jsonMw(r.handler))
} else {
- h = r.handler
+ mux.Handle(r.method, r.path, r.handler)
}
-
- mux.Handle(r.method, r.path, h)
}
return mux
}
-// Addrs returns all addresses on which this server serves the HTTP API. Addrs
-// must not be called until Start returns.
-func (svc *Service) Addrs() (addrs []string) {
- addrs = make([]string, 0, len(svc.servers))
+// addrs returns all addresses on which this server serves the HTTP API. addrs
+// 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) addrs() (addrs, secureAddrs []netip.AddrPort) {
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.
@@ -149,9 +204,6 @@ func (svc *Service) handleGetHealthCheck(w http.ResponseWriter, _ *http.Request)
_, _ = io.WriteString(w, "OK")
}
-// unit is a convenient alias for struct{}.
-type unit = struct{}
-
// type check
var _ agh.Service = (*Service)(nil)
@@ -163,11 +215,9 @@ func (svc *Service) Start() (err error) {
return nil
}
- srvs := svc.servers
-
wg := &sync.WaitGroup{}
- wg.Add(len(srvs))
- for _, srv := range srvs {
+ wg.Add(len(svc.servers))
+ for _, srv := range svc.servers {
go serve(srv, wg)
}
@@ -181,11 +231,14 @@ func serve(srv *http.Server, wg *sync.WaitGroup) {
addr := srv.Addr
defer log.OnPanic(addr)
+ var proto string
var l net.Listener
var err error
if srv.TLSConfig == nil {
+ proto = "http"
l, err = net.Listen("tcp", addr)
} else {
+ proto = "https"
l, err = tls.Listen("tcp", addr, srv.TLSConfig)
}
if err != nil {
@@ -196,8 +249,12 @@ func serve(srv *http.Server, wg *sync.WaitGroup) {
// would mean that a random available port was automatically chosen.
srv.Addr = l.Addr().String()
- log.Info("websvc: starting srv http://%s", srv.Addr)
- wg.Done()
+ log.Info("websvc: starting srv %s://%s", proto, srv.Addr)
+
+ l = &waitListener{
+ Listener: l,
+ firstAcceptWG: wg,
+ }
err = srv.Serve(l)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -221,8 +278,28 @@ func (svc *Service) Shutdown(ctx context.Context) (err error) {
}
if len(errs) > 0 {
- return errors.List("shutting down")
+ return errors.List("shutting down", errs...)
}
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
+}
diff --git a/internal/next/websvc/websvc_internal_test.go b/internal/next/websvc/websvc_internal_test.go
new file mode 100644
index 00000000..3509b193
--- /dev/null
+++ b/internal/next/websvc/websvc_internal_test.go
@@ -0,0 +1,6 @@
+package websvc
+
+import "time"
+
+// testTimeout is the common timeout for tests.
+const testTimeout = 1 * time.Second
diff --git a/internal/next/websvc/websvc_test.go b/internal/next/websvc/websvc_test.go
new file mode 100644
index 00000000..39ab3038
--- /dev/null
+++ b/internal/next/websvc/websvc_test.go
@@ -0,0 +1,188 @@
+package websvc_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/netip"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/AdguardTeam/AdGuardHome/internal/aghtest"
+ "github.com/AdguardTeam/AdGuardHome/internal/next/agh"
+ "github.com/AdguardTeam/AdGuardHome/internal/next/dnssvc"
+ "github.com/AdguardTeam/AdGuardHome/internal/next/websvc"
+ "github.com/AdguardTeam/golibs/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMain(m *testing.M) {
+ aghtest.DiscardLogOutput(m)
+}
+
+// testTimeout is the common timeout for tests.
+const testTimeout = 1 * time.Second
+
+// testStart is the server start value for tests.
+var testStart = time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
+
+// type check
+var _ websvc.ConfigManager = (*configManager)(nil)
+
+// configManager is a [websvc.ConfigManager] for tests.
+type configManager struct {
+ onDNS func() (svc agh.ServiceWithConfig[*dnssvc.Config])
+ onWeb func() (svc agh.ServiceWithConfig[*websvc.Config])
+
+ onUpdateDNS func(ctx context.Context, c *dnssvc.Config) (err error)
+ onUpdateWeb func(ctx context.Context, c *websvc.Config) (err error)
+}
+
+// DNS implements the [websvc.ConfigManager] interface for *configManager.
+func (m *configManager) DNS() (svc agh.ServiceWithConfig[*dnssvc.Config]) {
+ return m.onDNS()
+}
+
+// Web implements the [websvc.ConfigManager] interface for *configManager.
+func (m *configManager) Web() (svc agh.ServiceWithConfig[*websvc.Config]) {
+ return m.onWeb()
+}
+
+// UpdateDNS implements the [websvc.ConfigManager] interface for *configManager.
+func (m *configManager) UpdateDNS(ctx context.Context, c *dnssvc.Config) (err error) {
+ return m.onUpdateDNS(ctx, c)
+}
+
+// UpdateWeb implements the [websvc.ConfigManager] interface for *configManager.
+func (m *configManager) UpdateWeb(ctx context.Context, c *websvc.Config) (err error) {
+ return m.onUpdateWeb(ctx, c)
+}
+
+// newConfigManager returns a *configManager all methods of which panic.
+func newConfigManager() (m *configManager) {
+ return &configManager{
+ onDNS: func() (svc agh.ServiceWithConfig[*dnssvc.Config]) { panic("not implemented") },
+ onWeb: func() (svc agh.ServiceWithConfig[*websvc.Config]) { panic("not implemented") },
+ onUpdateDNS: func(_ context.Context, _ *dnssvc.Config) (err error) {
+ panic("not implemented")
+ },
+ onUpdateWeb: func(_ context.Context, _ *websvc.Config) (err error) {
+ panic("not implemented")
+ },
+ }
+}
+
+// newTestServer creates and starts a new web service instance as well as its
+// sole address. It also registers a cleanup procedure, which shuts the
+// instance down.
+//
+// TODO(a.garipov): Use svc or remove it.
+func newTestServer(
+ t testing.TB,
+ confMgr websvc.ConfigManager,
+) (svc *websvc.Service, addr netip.AddrPort) {
+ t.Helper()
+
+ c := &websvc.Config{
+ ConfigManager: confMgr,
+ TLS: nil,
+ Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:0")},
+ SecureAddresses: nil,
+ Timeout: testTimeout,
+ Start: testStart,
+ ForceHTTPS: false,
+ }
+
+ svc = websvc.New(c)
+
+ err := svc.Start()
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+ t.Cleanup(cancel)
+
+ err = svc.Shutdown(ctx)
+ require.NoError(t, err)
+ })
+
+ c = svc.Config()
+ require.NotNil(t, c)
+ require.Len(t, c.Addresses, 1)
+
+ return svc, c.Addresses[0]
+}
+
+// jobj is a utility alias for JSON objects.
+type jobj map[string]any
+
+// httpGet is a helper that performs an HTTP GET request and returns the body of
+// the response as well as checks that the status code is correct.
+//
+// TODO(a.garipov): Add helpers for other methods.
+func httpGet(t testing.TB, u *url.URL, wantCode int) (body []byte) {
+ t.Helper()
+
+ req, err := http.NewRequest(http.MethodGet, u.String(), nil)
+ require.NoErrorf(t, err, "creating req")
+
+ httpCli := &http.Client{
+ Timeout: testTimeout,
+ }
+ resp, err := httpCli.Do(req)
+ require.NoErrorf(t, err, "performing req")
+ require.Equal(t, wantCode, resp.StatusCode)
+
+ testutil.CleanupAndRequireSuccess(t, resp.Body.Close)
+
+ body, err = io.ReadAll(resp.Body)
+ require.NoErrorf(t, err, "reading body")
+
+ return body
+}
+
+// httpPatch is a helper that performs an HTTP PATCH request with JSON-encoded
+// reqBody as the request body and returns the body of the response as well as
+// checks that the status code is correct.
+//
+// TODO(a.garipov): Add helpers for other methods.
+func httpPatch(t testing.TB, u *url.URL, reqBody any, wantCode int) (body []byte) {
+ t.Helper()
+
+ b, err := json.Marshal(reqBody)
+ require.NoErrorf(t, err, "marshaling reqBody")
+
+ req, err := http.NewRequest(http.MethodPatch, u.String(), bytes.NewReader(b))
+ require.NoErrorf(t, err, "creating req")
+
+ httpCli := &http.Client{
+ Timeout: testTimeout,
+ }
+ resp, err := httpCli.Do(req)
+ require.NoErrorf(t, err, "performing req")
+ require.Equal(t, wantCode, resp.StatusCode)
+
+ testutil.CleanupAndRequireSuccess(t, resp.Body.Close)
+
+ body, err = io.ReadAll(resp.Body)
+ require.NoErrorf(t, err, "reading body")
+
+ return body
+}
+
+func TestService_Start_getHealthCheck(t *testing.T) {
+ confMgr := newConfigManager()
+ _, addr := newTestServer(t, confMgr)
+ u := &url.URL{
+ Scheme: "http",
+ Host: addr.String(),
+ Path: websvc.PathHealthCheck,
+ }
+
+ body := httpGet(t, u, http.StatusOK)
+
+ assert.Equal(t, []byte("OK"), body)
+}
diff --git a/internal/querylog/http.go b/internal/querylog/http.go
index 11f62d0d..1fab138e 100644
--- a/internal/querylog/http.go
+++ b/internal/querylog/http.go
@@ -1,7 +1,6 @@
package querylog
import (
- "encoding/json"
"fmt"
"net"
"net/http"
@@ -48,24 +47,7 @@ func (l *queryLog) handleQueryLog(w http.ResponseWriter, r *http.Request) {
// convert log entries to JSON
data := l.entriesToJSON(entries, oldest)
- jsonVal, err := json.Marshal(data)
- if err != nil {
- aghhttp.Error(
- r,
- w,
- http.StatusInternalServerError,
- "Couldn't marshal data into json: %s",
- err,
- )
-
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- _, err = w.Write(jsonVal)
- if err != nil {
- aghhttp.Error(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
- }
+ _ = aghhttp.WriteJSONResponse(w, r, data)
}
func (l *queryLog) handleQueryLogClear(_ http.ResponseWriter, _ *http.Request) {
@@ -74,23 +56,13 @@ func (l *queryLog) handleQueryLogClear(_ http.ResponseWriter, _ *http.Request) {
// Get configuration
func (l *queryLog) handleQueryLogInfo(w http.ResponseWriter, r *http.Request) {
- resp := qlogConfig{}
- resp.Enabled = l.conf.Enabled
- resp.Interval = l.conf.RotationIvl.Hours() / 24
- resp.AnonymizeClientIP = l.conf.AnonymizeClientIP
-
- jsonVal, err := json.Marshal(resp)
- if err != nil {
- aghhttp.Error(r, w, http.StatusInternalServerError, "json encode: %s", err)
-
- return
+ resp := qlogConfig{
+ Enabled: l.conf.Enabled,
+ Interval: l.conf.RotationIvl.Hours() / 24,
+ AnonymizeClientIP: l.conf.AnonymizeClientIP,
}
- w.Header().Set("Content-Type", "application/json")
- _, err = w.Write(jsonVal)
- if err != nil {
- aghhttp.Error(r, w, http.StatusInternalServerError, "http write: %s", err)
- }
+ _ = aghhttp.WriteJSONResponse(w, r, resp)
}
// AnonymizeIP masks ip to anonymize the client if the ip is a valid one.
diff --git a/internal/stats/http.go b/internal/stats/http.go
index ae980bf3..b06a7cdc 100644
--- a/internal/stats/http.go
+++ b/internal/stats/http.go
@@ -55,12 +55,7 @@ func (s *StatsCtx) handleStats(w http.ResponseWriter, r *http.Request) {
return
}
- w.Header().Set("Content-Type", "application/json")
-
- err := json.NewEncoder(w).Encode(resp)
- if err != nil {
- aghhttp.Error(r, w, http.StatusInternalServerError, "json encode: %s", err)
- }
+ _ = aghhttp.WriteJSONResponse(w, r, resp)
}
// configResp is the response to the GET /control/stats_info.
@@ -71,13 +66,7 @@ type configResp struct {
// handleStatsInfo handles requests to the GET /control/stats_info endpoint.
func (s *StatsCtx) handleStatsInfo(w http.ResponseWriter, r *http.Request) {
resp := configResp{IntervalDays: atomic.LoadUint32(&s.limitHours) / 24}
-
- w.Header().Set("Content-Type", "application/json")
-
- err := json.NewEncoder(w).Encode(resp)
- if err != nil {
- aghhttp.Error(r, w, http.StatusInternalServerError, "json encode: %s", err)
- }
+ _ = aghhttp.WriteJSONResponse(w, r, resp)
}
// handleStatsConfig handles requests to the POST /control/stats_config
diff --git a/internal/v1/agh/agh.go b/internal/v1/agh/agh.go
deleted file mode 100644
index 212da4d6..00000000
--- a/internal/v1/agh/agh.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Package agh contains common entities and interfaces of AdGuard Home.
-//
-// TODO(a.garipov): Move to the upper-level internal/.
-package agh
-
-import "context"
-
-// Service is the interface for API servers.
-//
-// TODO(a.garipov): Consider adding a context to Start.
-//
-// TODO(a.garipov): Consider adding a Wait method or making an extension
-// interface for that.
-type Service interface {
- // Start starts the service. It does not block.
- Start() (err error)
-
- // Shutdown gracefully stops the service. ctx is used to determine
- // a timeout before trying to stop the service less gracefully.
- Shutdown(ctx context.Context) (err error)
-}
-
-// type check
-var _ Service = EmptyService{}
-
-// EmptyService is a Service that does nothing.
-type EmptyService struct{}
-
-// Start implements the Service interface for EmptyService.
-func (EmptyService) Start() (err error) { return nil }
-
-// Shutdown implements the Service interface for EmptyService.
-func (EmptyService) Shutdown(_ context.Context) (err error) { return nil }
diff --git a/internal/v1/cmd/signal.go b/internal/v1/cmd/signal.go
deleted file mode 100644
index b66075f6..00000000
--- a/internal/v1/cmd/signal.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package cmd
-
-import (
- "os"
-
- "github.com/AdguardTeam/AdGuardHome/internal/aghos"
- "github.com/AdguardTeam/AdGuardHome/internal/v1/agh"
- "github.com/AdguardTeam/golibs/log"
-)
-
-// signalHandler processes incoming signals and shuts services down.
-type signalHandler struct {
- signal chan os.Signal
-
- // services are the services that are shut down before application
- // exiting.
- services []agh.Service
-}
-
-// handle processes OS signals.
-func (h *signalHandler) handle() {
- defer log.OnPanic("signalHandler.handle")
-
- for sig := range h.signal {
- log.Info("sighdlr: received signal %q", sig)
-
- if aghos.IsShutdownSignal(sig) {
- h.shutdown()
- }
- }
-}
-
-// Exit status constants.
-const (
- statusSuccess = 0
- statusError = 1
-)
-
-// shutdown gracefully shuts down all services.
-func (h *signalHandler) shutdown() {
- ctx, cancel := ctxWithDefaultTimeout()
- defer cancel()
-
- status := statusSuccess
-
- log.Info("sighdlr: shutting down services")
- for i, service := range h.services {
- err := service.Shutdown(ctx)
- if err != nil {
- log.Error("sighdlr: shutting down service at index %d: %s", i, err)
- status = statusError
- }
- }
-
- log.Info("sighdlr: shutting down adguard home")
-
- os.Exit(status)
-}
-
-// newSignalHandler returns a new signalHandler that shuts down svcs.
-func newSignalHandler(svcs ...agh.Service) (h *signalHandler) {
- h = &signalHandler{
- signal: make(chan os.Signal, 1),
- services: svcs,
- }
-
- aghos.NotifyShutdownSignal(h.signal)
-
- return h
-}
diff --git a/internal/v1/websvc/json.go b/internal/v1/websvc/json.go
deleted file mode 100644
index ef84211b..00000000
--- a/internal/v1/websvc/json.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package websvc
-
-import (
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "strconv"
- "time"
-
- "github.com/AdguardTeam/golibs/log"
-)
-
-// JSON Utilities
-
-// 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{}
-
-// nsecPerMsec is the number of nanoseconds in a millisecond.
-const nsecPerMsec = float64(time.Millisecond / time.Nanosecond)
-
-// 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', 3, 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
-}
-
-// writeJSONResponse encodes v into w and logs any errors it encounters. r is
-// used to get additional information from the request.
-func writeJSONResponse(w io.Writer, r *http.Request, v any) {
- err := json.NewEncoder(w).Encode(v)
- if err != nil {
- log.Error("websvc: writing resp to %s %s: %s", r.Method, r.URL.Path, err)
- }
-}
diff --git a/internal/v1/websvc/path.go b/internal/v1/websvc/path.go
deleted file mode 100644
index cfd67fd9..00000000
--- a/internal/v1/websvc/path.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package websvc
-
-// Path constants
-const (
- PathHealthCheck = "/health-check"
-
- PathV1SystemInfo = "/api/v1/system/info"
-)
diff --git a/internal/v1/websvc/websvc_test.go b/internal/v1/websvc/websvc_test.go
deleted file mode 100644
index de4a9f5d..00000000
--- a/internal/v1/websvc/websvc_test.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package websvc_test
-
-import (
- "context"
- "io"
- "net/http"
- "net/netip"
- "net/url"
- "testing"
- "time"
-
- "github.com/AdguardTeam/AdGuardHome/internal/v1/websvc"
- "github.com/AdguardTeam/golibs/testutil"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-const testTimeout = 1 * time.Second
-
-// testStart is the server start value for tests.
-var testStart = time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
-
-// newTestServer creates and starts a new web service instance as well as its
-// sole address. It also registers a cleanup procedure, which shuts the
-// instance down.
-//
-// TODO(a.garipov): Use svc or remove it.
-func newTestServer(t testing.TB) (svc *websvc.Service, addr string) {
- t.Helper()
-
- c := &websvc.Config{
- TLS: nil,
- Addresses: []netip.AddrPort{netip.MustParseAddrPort("127.0.0.1:0")},
- SecureAddresses: nil,
- Timeout: testTimeout,
- Start: testStart,
- }
-
- svc = websvc.New(c)
-
- err := svc.Start()
- require.NoError(t, err)
- t.Cleanup(func() {
- ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
- t.Cleanup(cancel)
-
- err = svc.Shutdown(ctx)
- require.NoError(t, err)
- })
-
- addrs := svc.Addrs()
- require.Len(t, addrs, 1)
-
- return svc, addrs[0]
-}
-
-// httpGet is a helper that performs an HTTP GET request and returns the body of
-// the response as well as checks that the status code is correct.
-//
-// TODO(a.garipov): Add helpers for other methods.
-func httpGet(t testing.TB, u *url.URL, wantCode int) (body []byte) {
- t.Helper()
-
- req, err := http.NewRequest(http.MethodGet, u.String(), nil)
- require.NoErrorf(t, err, "creating req")
-
- httpCli := &http.Client{
- Timeout: testTimeout,
- }
- resp, err := httpCli.Do(req)
- require.NoErrorf(t, err, "performing req")
- require.Equal(t, wantCode, resp.StatusCode)
-
- testutil.CleanupAndRequireSuccess(t, resp.Body.Close)
-
- body, err = io.ReadAll(resp.Body)
- require.NoErrorf(t, err, "reading body")
-
- return body
-}
-
-func TestService_Start_getHealthCheck(t *testing.T) {
- _, addr := newTestServer(t)
- u := &url.URL{
- Scheme: "http",
- Host: addr,
- Path: websvc.PathHealthCheck,
- }
-
- body := httpGet(t, u, http.StatusOK)
-
- assert.Equal(t, []byte("OK"), body)
-}
diff --git a/internal/version/version.go b/internal/version/version.go
index 2091d859..ca78efff 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -63,14 +63,6 @@ func Version() (v string) {
return version
}
-// Constants defining the format of module information string.
-const (
- modInfoAtSep = "@"
- modInfoDevSep = " "
- modInfoSumLeft = " (sum: "
- modInfoSumRight = ")"
-)
-
// fmtModule returns formatted information about module. The result looks like:
//
// github.com/Username/module@v1.2.3 (sum: someHASHSUM=)
@@ -87,14 +79,16 @@ func fmtModule(m *debug.Module) (formatted string) {
stringutil.WriteToBuilder(b, m.Path)
if ver := m.Version; ver != "" {
- sep := modInfoAtSep
+ sep := "@"
if ver == "(devel)" {
- sep = modInfoDevSep
+ sep = " "
}
+
stringutil.WriteToBuilder(b, sep, ver)
}
+
if sum := m.Sum; sum != "" {
- stringutil.WriteToBuilder(b, modInfoSumLeft, sum, modInfoSumRight)
+ stringutil.WriteToBuilder(b, "(sum: ", sum, ")")
}
return b.String()
diff --git a/main.go b/main.go
index 03ad2f03..615a8a86 100644
--- a/main.go
+++ b/main.go
@@ -1,5 +1,5 @@
-//go:build !v1
-// +build !v1
+//go:build !next
+// +build !next
package main
diff --git a/main_v1.go b/main_next.go
similarity index 79%
rename from main_v1.go
rename to main_next.go
index 6b5f3dea..0006e87b 100644
--- a/main_v1.go
+++ b/main_next.go
@@ -1,12 +1,12 @@
-//go:build v1
-// +build v1
+//go:build next
+// +build next
package main
import (
"embed"
- "github.com/AdguardTeam/AdGuardHome/internal/v1/cmd"
+ "github.com/AdguardTeam/AdGuardHome/internal/next/cmd"
)
// Embed the prebuilt client here since we strive to keep .go files inside the
diff --git a/openapi/v1.yaml b/openapi/v1.yaml
index 77eb1a09..adab6d4d 100644
--- a/openapi/v1.yaml
+++ b/openapi/v1.yaml
@@ -2289,7 +2289,7 @@
'upstream_servers':
- '1.1.1.1'
- '8.8.8.8'
- 'upstream_timeout': '1s'
+ 'upstream_timeout': 1000
'required':
- 'addresses'
- 'blocking_mode'
@@ -2397,8 +2397,9 @@
'type': 'array'
'upstream_timeout':
'description': >
- Upstream request timeout, as a human readable duration.
- 'type': 'string'
+ Upstream request timeout, in milliseconds.
+ 'format': 'double'
+ 'type': 'number'
'type': 'object'
'DnsType':
@@ -3505,14 +3506,16 @@
'addresses':
- '127.0.0.1:80'
- '192.168.1.1:80'
+ 'force_https': true
'secure_addresses':
- '127.0.0.1:443'
- '192.168.1.1:443'
- 'force_https': true
+ 'timeout': 10000
'required':
- 'addresses'
- - 'secure_addresses'
- 'force_https'
+ - 'secure_addresses'
+ - 'timeout'
'HttpSettingsPatch':
'description': >
@@ -3539,6 +3542,11 @@
'items':
'type': 'string'
'type': 'array'
+ 'timeout':
+ 'description': >
+ HTTP request timeout, in milliseconds.
+ 'format': 'double'
+ 'type': 'number'
'type': 'object'
'InternalServerErrorResp':
diff --git a/scripts/make/go-build.sh b/scripts/make/go-build.sh
index c998a611..8d993d66 100644
--- a/scripts/make/go-build.sh
+++ b/scripts/make/go-build.sh
@@ -124,11 +124,11 @@ GO111MODULE='on'
export CGO_ENABLED GO111MODULE
# Build the new binary if requested.
-if [ "${V1API:-0}" -eq '0' ]
+if [ "${NEXTAPI:-0}" -eq '0' ]
then
tags_flags='--tags='
else
- tags_flags='--tags=v1'
+ tags_flags='--tags=next'
fi
readonly tags_flags
diff --git a/scripts/make/go-lint.sh b/scripts/make/go-lint.sh
index 2cdcc90d..8c462d5b 100644
--- a/scripts/make/go-lint.sh
+++ b/scripts/make/go-lint.sh
@@ -136,11 +136,11 @@ underscores() {
-e '_freebsd.go'\
-e '_linux.go'\
-e '_little.go'\
+ -e '_next.go'\
-e '_openbsd.go'\
-e '_others.go'\
-e '_test.go'\
-e '_unix.go'\
- -e '_v1.go'\
-e '_windows.go' \
-v\
| sed -e 's/./\t\0/'
@@ -223,13 +223,12 @@ govulncheck ./...
# Apply more lax standards to the code we haven't properly refactored yet.
gocyclo --over 17 ./internal/querylog/
-gocyclo --over 15 ./internal/home/ ./internal/dhcpd
-gocyclo --over 13 ./internal/filtering/
+gocyclo --over 13 ./internal/dhcpd ./internal/filtering/ ./internal/home/
# Apply stricter standards to new or somewhat refactored code.
gocyclo --over 10 ./internal/aghio/ ./internal/aghnet/ ./internal/aghos/\
./internal/aghtest/ ./internal/dnsforward/ ./internal/stats/\
- ./internal/tools/ ./internal/updater/ ./internal/v1/ ./internal/version/\
+ ./internal/tools/ ./internal/updater/ ./internal/next/ ./internal/version/\
./main.go
ineffassign ./...