diff --git a/README.md b/README.md index 9fab3f8..bdf288d 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,21 @@ # 🔌 PlugNPiN -**Plug and play your docker containers into Pi-Hole & Nginx Proxy Manager** +**Plug and play your docker containers into Pi-Hole/AdGuard Home & Nginx Proxy Manager** Automatically detect running Docker containers based on labels, add them -as local DNS/CNAME records in **Pi-Hole** and create matching proxy hosts in +as local DNS/CNAME records in **Pi-Hole** (or DNS Rewrites in **AdGuard Home**) and create matching proxy hosts in **Nginx Proxy Manager**. ## Key Features - Automatic Docker container detection. -- Local DNS/CNAME record creation in Pi-hole. +- Local DNS/CNAME record creation/deletion in Pi-hole. +- DNS Rewrites creation/deletion in AdGuard Home. - Nginx Proxy Manager host creation. - Support for Docker socket proxy. -**Due to popular request, Pi-Hole's functionality can be disabled.** +**Pi-Hole's and AdGuard Home's functionality can be toggled individually. By default Pi-Hole is enabled and AdGuard Home is disabled.** **[See the documentation site for full setup and configuration.](https://deepspace2.github.io/PlugNPiN)** @@ -37,7 +38,7 @@ The application operates in two complementary modes to keep your services synchr When a container is processed in either mode, PlugNPiN will: -1. Create a DNS record pointing the specified `url` to the `ip` address on **Pi-Hole** (or a CNAME record pointing to a configurable target domain). +1. Create a DNS record pointing the specified `url` to the `ip` address on **Pi-Hole/AdGuard Home** (or a CNAME record pointing to a configurable target domain). 2. Create a proxy host to route traffic from the `url` to the container's `ip` and `port` on **Nginx Proxy Manager**. ## Usage diff --git a/docs/index.md b/docs/index.md index f1286e8..37d3961 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,12 +5,15 @@ # 🔌 PlugNPiN -**Plug and play your docker containers into Pi-Hole & Nginx Proxy Manager** +**Plug and play your docker containers into Pi-Hole/AdGuard Home & Nginx Proxy Manager** Automatically detect running Docker containers based on labels, add them -as local DNS/[CNAME](#targetDomainLabel) records in **Pi-Hole** and create matching proxy hosts in +as local DNS/[CNAME](#piholeTargetDomainLabel) records in **Pi-Hole** (or DNS Rewrites in **AdGuard Home**) and create matching proxy hosts in **Nginx Proxy Manager**. +**Pi-Hole's and AdGuard Home's functionality can be toggled individually. By default Pi-Hole is enabled and AdGuard Home is disabled.** +See [Optional Environment Variables](#optional). + ## How It Works PlugNPiN discovers services by scanning for Docker containers that have the following labels: @@ -26,13 +29,23 @@ The application operates in two complementary modes to keep your services synchr When a container is processed in either mode, PlugNPiN will: -1. Create a DNS record pointing the specified `url` to the `ip` address on **Pi-Hole** (or a [CNAME record](#targetDomainLabel) pointing to a configurable target domain). +1. Create a DNS record pointing the specified `url` to the `ip` address on **Pi-Hole/AdGuard Home** (or a CNAME record pointing to a configurable target domain). 2. Create a proxy host to route traffic from the `url` to the container's `ip` and `port` on **Nginx Proxy Manager**. ### CNAME Records -It is possible to force PlugNPiN to create CNAME records instead of local DNS records ("A record") in Pi-Hole by setting the `plugNPiN.piholeOptions.targetDomain` label. -See [Per Container Configuration ➔ Pi-Hole](#targetDomainLabel). +#### AdGuard Home + +To create a DNS Rewrite as a CNAME, set the `plugNPiN.adguardHomeOptions.targetDomain` label. + +See [Per Container Configuration ➔ AdGuard Home](#adguardHomeTargetDomainLabel). + +#### Pi-Hole + +To create A CNAME record instead of local DNS records ("A record"), set the `plugNPiN.piholeOptions.targetDomain` label. + +See [Per Container Configuration ➔ Pi-Hole](#piholeTargetDomainLabel). + ## Configuration @@ -42,6 +55,9 @@ See [Per Container Configuration ➔ Pi-Hole](#targetDomainLabel). | Variable {: style="width:35%" } | Description | Notes | |---|---|---| +| `ADGUARD_HOME_HOST` | The URL of your AdGuard Home instance | Only required if [`ADGUARD_HOME_DISABLED`](#adguardHomeDisabledEnvVar) is set to `false` | +| `ADGUARD_HOME_USERNAME` | Your AdGuard Home username | Only required if [`ADGUARD_HOME_DISABLED`](#adguardHomeDisabledEnvVar) is set to `false` | +| `ADGUARD_HOME_PASSWORD` | Your AdGuard Home password | Only required if [`ADGUARD_HOME_DISABLED`](#adguardHomeDisabledEnvVar) is set to `false` | | `NGINX_PROXY_MANAGER_HOST` | The URL of your Nginx Proxy Manager instance. | | | `NGINX_PROXY_MANAGER_USERNAME` | Your Nginx Proxy Manager username. | | | `NGINX_PROXY_MANAGER_PASSWORD` | Your Nginx Proxy Manager password.
**Important:** It is recommended to create a new non-admin user with only the "Proxy Hosts - Manage" permission. | | @@ -52,6 +68,7 @@ See [Per Container Configuration âž” Pi-Hole](#targetDomainLabel). | Variable {: style="width:35%" } | Description | Default {: style="width:10%" } | |---|---|---| +|
`ADGUARD_HOME_DISABLED`
| Set to `false` to enable AdGuard Home functionality | `true` | | `DEBUG` | Set to `true` to enable DEBUG level logs | `false` | | `DOCKER_HOST` | The URL of a docker socket proxy. If set, you don't need to mount the docker socket as a volume. Querying containers must be allowed (typically done by setting the `CONTAINERS` environment variable to `1`). | *None* | |
`PIHOLE_DISABLED`
| Set to `true` to disable Pi-Hole functionality | `false` | @@ -66,6 +83,12 @@ See [Per Container Configuration âž” Pi-Hole](#targetDomainLabel). ### Per Container Configuration +#### AdGuard Home + +| Label {: style="width:45%"} | Description | Default {: style="width:10%"} | +|---|---|---| +|
`plugNPiN.adguardHomeOptions.targetDomain`
| If provided, a CNAME DNS Rewrite will be created | | + #### Nginx Proxy Manager Use the following labels to configure Nginx Proxy Manager entries @@ -87,7 +110,8 @@ Use the following labels to configure Nginx Proxy Manager entries | Label {: style="width:35%"} | Description | Default {: style="width:10%"} | |---|---|---| -|
`plugNPiN.piholeOptions.targetDomain`
| If provided, a CNAME record will be created **instead** of a DNS record | | +|
`plugNPiN.piholeOptions.targetDomain`
| If provided, a CNAME record will be created **instead** of a DNS record | | + ## Usage diff --git a/main.go b/main.go index a732a46..85dd6f0 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "syscall" "github.com/deepspace2/plugnpin/pkg/cli" + "github.com/deepspace2/plugnpin/pkg/clients/adguardhome" "github.com/deepspace2/plugnpin/pkg/clients/docker" "github.com/deepspace2/plugnpin/pkg/clients/npm" "github.com/deepspace2/plugnpin/pkg/clients/pihole" @@ -50,6 +51,7 @@ func main() { log.Info(fmt.Sprintf("Will run every %v", conf.RunInterval)) } + var adguardHomeClient *adguardhome.Client var piholeClient *pihole.Client var npmClient *npm.Client @@ -63,6 +65,10 @@ func main() { } } + if !conf.AdguardHomeDisabled { + adguardHomeClient = adguardhome.NewClient(conf.AdguardHomeHost, conf.AdguardHomeUsername, conf.AdguardHomePassword) + } + npmClient = npm.NewClient(conf.NpmHost, conf.NpmUsername, conf.NpmPassword) err = npmClient.Login() if err != nil { @@ -78,7 +84,7 @@ func main() { } defer dockerClient.Close() - proc := processor.New(dockerClient, piholeClient, npmClient, cliFlags.DryRun) + proc := processor.New(dockerClient, adguardHomeClient, piholeClient, npmClient, cliFlags.DryRun) if conf.RunInterval == 0 { log.Info("RUN_INTERVAL is 0, will run once") diff --git a/pkg/clients/adguardhome/adguardhome.go b/pkg/clients/adguardhome/adguardhome.go new file mode 100644 index 0000000..7515e07 --- /dev/null +++ b/pkg/clients/adguardhome/adguardhome.go @@ -0,0 +1,96 @@ +package adguardhome + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/deepspace2/plugnpin/pkg/clients" + "github.com/deepspace2/plugnpin/pkg/logging" +) + +var log = logging.GetLogger() + +type Client struct { + http.Client + baseURL string +} + +var headers map[string]string = map[string]string{ + "accept": "application/json", + "content-type": "application/json", + "authorization": "", +} + +func NewClient(baseURL, username, password string) *Client { + base64encodedCredentials := base64.StdEncoding.EncodeToString([]byte(fmt.Appendf([]byte{}, "%s:%s", username, password))) + headers["authorization"] = fmt.Sprintf("Basic %s", base64encodedCredentials) + return &Client{ + http.Client{}, + fmt.Sprintf("%v/control", baseURL), + } +} + +func (ad *Client) getDnsRewrites() (DnsRewrites, error) { + dnsRewritesResponseString, _, err := clients.Get(&ad.Client, ad.baseURL+"/rewrite/list", headers) + if err != nil { + return nil, err + } + var resp []DnsRewrite + json.Unmarshal([]byte(dnsRewritesResponseString), &resp) + + dnsRewrites := DnsRewrites{} + for _, rawDnsRewrite := range resp { + dnsRewrites[DomainName(rawDnsRewrite.Domain)] = IP(rawDnsRewrite.Answer) + } + return dnsRewrites, nil +} + +func (ad *Client) AddDnsRewrite(domain, ip string) error { + existingRecords, err := ad.getDnsRewrites() + if err != nil { + return err + } + d := DomainName(domain) + _, exists := existingRecords[d] + + if exists { + return nil + } + + payload, err := json.Marshal(DnsRewrite{Answer: ip, Domain: domain, Enabled: true}) + if err != nil { + return err + } + payloadString := string(payload) + _, statusCode, err := clients.Post(&ad.Client, ad.baseURL+"/rewrite/add", headers, &payloadString) + if err != nil { + return err + } + + if statusCode == 401 { + return errors.New("Unauthorized") + } + + return nil +} + +func (ad *Client) DeleteDnsRewrite(domain, ip string) error { + payload, err := json.Marshal(DnsRewrite{Answer: ip, Domain: domain, Enabled: true}) + if err != nil { + return err + } + payloadString := string(payload) + _, statusCode, err := clients.Post(&ad.Client, ad.baseURL+"/rewrite/delete", headers, &payloadString) + if err != nil { + return err + } + + if statusCode == 401 { + return errors.New("Unauthorized") + } + + return nil +} diff --git a/pkg/clients/adguardhome/adguardhome_test.go b/pkg/clients/adguardhome/adguardhome_test.go new file mode 100644 index 0000000..545cf6a --- /dev/null +++ b/pkg/clients/adguardhome/adguardhome_test.go @@ -0,0 +1,139 @@ +package adguardhome + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// setupTestServer creates a new test server and a client pointing to it. +func setupTestServer(username, password string, handler http.Handler) (*Client, *httptest.Server) { + server := httptest.NewServer(handler) + client := NewClient(server.URL, username, password) + client.Client = *server.Client() + return client, server +} + +func TestAddDnsRewrite(t *testing.T) { + t.Run("successful add", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + expectedAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("testuser:testpass")) + assert.Equal(t, expectedAuth, auth) + assert.Equal(t, "application/json", r.Header.Get("accept")) + assert.Equal(t, "application/json", r.Header.Get("content-type")) + + if r.URL.Path == "/control/rewrite/list" && r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `[{"domain": "one.com", "answer": "1.1.1.1"}]`) + return + } + + if r.URL.Path == "/control/rewrite/add" && r.Method == http.MethodPost { + var payload DnsRewrite + err := json.NewDecoder(r.Body).Decode(&payload) + assert.NoError(t, err) + + assert.Equal(t, "test.com", payload.Domain) + assert.Equal(t, "1.2.3.4", payload.Answer) + assert.True(t, payload.Enabled) + + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{}`) + return + } + + t.Fatalf("Received unexpected request: %s %s", r.Method, r.URL.Path) + }) + + client, server := setupTestServer("testuser", "testpass", handler) + defer server.Close() + + err := client.AddDnsRewrite("test.com", "1.2.3.4") + assert.NoError(t, err) + }) +} + +func TestDeleteDnsRewrite(t *testing.T) { + t.Run("successful delete", func(t *testing.T) { + var deleteCalled bool + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + expectedAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("testuser:testpass")) + assert.Equal(t, expectedAuth, auth) + + if r.URL.Path == "/control/rewrite/delete" && r.Method == http.MethodPost { + deleteCalled = true + var payload DnsRewrite + err := json.NewDecoder(r.Body).Decode(&payload) + assert.NoError(t, err) + + assert.Equal(t, "test.com", payload.Domain) + assert.Equal(t, "1.2.3.4", payload.Answer) + assert.True(t, payload.Enabled) + + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{}`) + return + } + + if r.URL.Path == "/control/rewrite/list" && r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `[]`) + return + } + + t.Fatalf("Received unexpected request: %s %s", r.Method, r.URL.Path) + }) + + client, server := setupTestServer("testuser", "testpass", handler) + defer server.Close() + + err := client.DeleteDnsRewrite("test.com", "1.2.3.4") + assert.NoError(t, err) + assert.True(t, deleteCalled, "Delete API endpoint was not called") + + existingDnsRewrites, err := client.getDnsRewrites() + assert.NoError(t, err) + assert.Equal(t, 0, len(existingDnsRewrites)) + }) +} + +func TestWrongCredentials(t *testing.T) { + t.Run("wrong credentials", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/control/rewrite/delete" && r.Method == http.MethodPost { + auth := r.Header.Get("Authorization") + expectedAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("testuser:testpass")) + if auth != expectedAuth { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{}`) + } + return + } + + if r.URL.Path == "/control/rewrite/list" && r.Method == http.MethodGet { + auth := r.Header.Get("Authorization") + expectedAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("testuser:testpass")) + if auth != expectedAuth { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{}`) + } + return + } + + t.Fatalf("Received unexpected request: %s %s", r.Method, r.URL.Path) + }) + + client, server := setupTestServer("testuser", "wrongpass", handler) + defer server.Close() + + err := client.DeleteDnsRewrite("test.com", "1.2.3.4") + assert.Error(t, err) + }) +} diff --git a/pkg/clients/adguardhome/types.go b/pkg/clients/adguardhome/types.go new file mode 100644 index 0000000..b993512 --- /dev/null +++ b/pkg/clients/adguardhome/types.go @@ -0,0 +1,18 @@ +package adguardhome + +type DnsRewrite struct { + Answer string `json:"answer"` + Domain string `json:"domain"` + Enabled bool `json:"enabled"` +} + +type AdguardHomeOptions struct { + TargetDomain string +} + +type ( + DnsRewrites map[DomainName]IP + DomainName string + IP string + Target string +) diff --git a/pkg/clients/docker/docker.go b/pkg/clients/docker/docker.go index 39c78e8..9dac520 100644 --- a/pkg/clients/docker/docker.go +++ b/pkg/clients/docker/docker.go @@ -11,29 +11,37 @@ import ( "github.com/docker/docker/api/types/filters" dockerSdk "github.com/docker/go-sdk/client" + "github.com/deepspace2/plugnpin/pkg/clients/adguardhome" "github.com/deepspace2/plugnpin/pkg/clients/npm" "github.com/deepspace2/plugnpin/pkg/clients/pihole" "github.com/deepspace2/plugnpin/pkg/errors" "github.com/deepspace2/plugnpin/pkg/logging" ) +type ClientOptions struct { + AdguardHome *adguardhome.AdguardHomeOptions + Pihole *pihole.PiHoleOptions + NPM *npm.NpmProxyHostOptions +} + var log = logging.GetLogger() const ( ipLabel = "plugNPiN.ip" urlLabel = "plugNPiN.url" - npmOptionsAdvancedConfigLabel = "plugNPiN.npmOptions.advancedConfig" - npmOptionsBlockExploitsLabel = "plugNPiN.npmOptions.blockExploits" - npmOptionsCachingEnabledLabel = "plugNPiN.npmOptions.cachingEnabled" - npmOptionsCertificateNameLabel = "plugNPiN.npmOptions.certificateName" - npmOptionsHTTP2SupportLabel = "plugNPiN.npmOptions.http2Support" - npmOptionsHstsEnabledLabel = "plugNPiN.npmOptions.hstsEnabled" - npmOptionsHstsSubdomainsLabel = "plugNPiN.npmOptions.hstsSubdomains" - npmOptionsSchemeLabel = "plugNPiN.npmOptions.scheme" - npmOptionsSslForcedLabel = "plugNPiN.npmOptions.forceSsl" - npmOptionsWebsocketsSupportLabel = "plugNPiN.npmOptions.websocketsSupport" - piholeOptionsTargetDomainLabel = "plugNPiN.piholeOptions.targetDomain" + adguardHomeOptionsTargetDomainLabel = "plugNPiN.adguardHomeOptions.targetDomain" + npmOptionsAdvancedConfigLabel = "plugNPiN.npmOptions.advancedConfig" + npmOptionsBlockExploitsLabel = "plugNPiN.npmOptions.blockExploits" + npmOptionsCachingEnabledLabel = "plugNPiN.npmOptions.cachingEnabled" + npmOptionsCertificateNameLabel = "plugNPiN.npmOptions.certificateName" + npmOptionsHTTP2SupportLabel = "plugNPiN.npmOptions.http2Support" + npmOptionsHstsEnabledLabel = "plugNPiN.npmOptions.hstsEnabled" + npmOptionsHstsSubdomainsLabel = "plugNPiN.npmOptions.hstsSubdomains" + npmOptionsSchemeLabel = "plugNPiN.npmOptions.scheme" + npmOptionsSslForcedLabel = "plugNPiN.npmOptions.forceSsl" + npmOptionsWebsocketsSupportLabel = "plugNPiN.npmOptions.websocketsSupport" + piholeOptionsTargetDomainLabel = "plugNPiN.piholeOptions.targetDomain" ) var labels []string = []string{ipLabel, urlLabel} @@ -67,28 +75,30 @@ func GetParsedContainerName(container container.Summary) string { return strings.Trim(container.Names[0], "/") } -func GetValuesFromLabels(labels map[string]string) (ip, url string, port int, npmProxyHostOptions *npm.NpmProxyHostOptions, piholeOptions *pihole.PiHoleOptions, err error) { +func GetValuesFromLabels(labels map[string]string) (ip, url string, port int, opts *ClientOptions, err error) { ip, ok := labels[ipLabel] if !ok { - return "", "", 0, nil, nil, &errors.NonExistingLabelsError{Msg: fmt.Sprintf("missing %s label", ipLabel)} + return "", "", 0, nil, &errors.NonExistingLabelsError{Msg: fmt.Sprintf("missing %s label", ipLabel)} } url, ok = labels[urlLabel] if !ok { - return "", "", 0, nil, nil, &errors.NonExistingLabelsError{Msg: fmt.Sprintf("missing %s label", urlLabel)} + return "", "", 0, nil, &errors.NonExistingLabelsError{Msg: fmt.Sprintf("missing %s label", urlLabel)} } splitIPAndPort := strings.Split(ip, ":") if len(splitIPAndPort) == 1 { - return "", "", 0, nil, nil, &errors.MalformedIPLabelError{Msg: fmt.Sprintf("missing ':' in value of '%v' label", ipLabel)} + return "", "", 0, nil, &errors.MalformedIPLabelError{Msg: fmt.Sprintf("missing ':' in value of '%v' label", ipLabel)} } ip = splitIPAndPort[0] port, err = strconv.Atoi(splitIPAndPort[1]) if err != nil { - return "", "", 0, nil, nil, &errors.MalformedIPLabelError{ + return "", "", 0, nil, &errors.MalformedIPLabelError{ Msg: fmt.Sprintf("value after ':' in value of '%v' label must be an integer, got '%v'", ipLabel, splitIPAndPort[1]), } } + opts = &ClientOptions{} + npmOptionsBlockExploitsLabelValue, exists := labels[npmOptionsBlockExploitsLabel] if !exists { npmOptionsBlockExploitsLabelValue = "true" @@ -104,7 +114,7 @@ func GetValuesFromLabels(labels map[string]string) (ip, url string, port int, np } npmOptionsScheme = strings.ToLower(npmOptionsScheme) if !slices.Contains([]string{"http", "https"}, npmOptionsScheme) { - return "", "", 0, nil, nil, &errors.InvalidSchemeError{ + return "", "", 0, nil, &errors.InvalidSchemeError{ Msg: fmt.Sprintf("value of '%v' label must be one of 'http', 'https', got '%v'", npmOptionsSchemeLabel, npmOptionsScheme), } } @@ -116,7 +126,7 @@ func GetValuesFromLabels(labels map[string]string) (ip, url string, port int, np npmOptionsHstsSubdomains, _ := strconv.ParseBool(labels[npmOptionsHstsSubdomainsLabel]) npmOptionsSslForced, _ := strconv.ParseBool(labels[npmOptionsSslForcedLabel]) - npmProxyHostOptions = &npm.NpmProxyHostOptions{ + opts.NPM = &npm.NpmProxyHostOptions{ AdvancedConfig: npmOptionsAdvancedConfig, AllowWebsocketUpgrade: npmOptionsWebsocketsSupport, BlockExploits: npmOptionsBlockExploits, @@ -131,9 +141,15 @@ func GetValuesFromLabels(labels map[string]string) (ip, url string, port int, np piholeOptionsTargetDomain := labels[piholeOptionsTargetDomainLabel] - piholeOptions = &pihole.PiHoleOptions{ + opts.Pihole = &pihole.PiHoleOptions{ TargetDomain: piholeOptionsTargetDomain, } - return ip, url, port, npmProxyHostOptions, piholeOptions, nil + adguardHomeOptionsTargetDomain := labels[adguardHomeOptionsTargetDomainLabel] + + opts.AdguardHome = &adguardhome.AdguardHomeOptions{ + TargetDomain: adguardHomeOptionsTargetDomain, + } + + return ip, url, port, opts, nil } diff --git a/pkg/clients/docker/docker_test.go b/pkg/clients/docker/docker_test.go index 75c40c8..9c387e2 100644 --- a/pkg/clients/docker/docker_test.go +++ b/pkg/clients/docker/docker_test.go @@ -60,17 +60,18 @@ func TestGetParsedContainerName(t *testing.T) { func TestGetValuesFromContainerLabels(t *testing.T) { testCases := []struct { - name string - container container.Summary - expectedIP string - expectedURL string - expectedPort int - expectedErr error - expectedNpmOptionsBlockExploits bool - expectedNpmOptionsCachingEnabled bool - expectedNpmOptionsScheme string - expectedNpmOptionsWebsocketsSupport bool - expectedPiholeOptionsTargetDomain string + name string + container container.Summary + expectedIP string + expectedURL string + expectedPort int + expectedErr error + expectedAdguardHomeOptionsTargetDomain string + expectedNpmOptionsBlockExploits bool + expectedNpmOptionsCachingEnabled bool + expectedNpmOptionsScheme string + expectedNpmOptionsWebsocketsSupport bool + expectedPiholeOptionsTargetDomain string }{ { name: "Happy path", @@ -261,22 +262,46 @@ func TestGetValuesFromContainerLabels(t *testing.T) { expectedNpmOptionsBlockExploits: true, expectedPiholeOptionsTargetDomain: "custom.domain", }, + { + name: "AdguardHome options - target domain", + container: container.Summary{ + Labels: map[string]string{ + ipLabel: "192.168.1.10:8080", + urlLabel: "my-service.example.com", + adguardHomeOptionsTargetDomainLabel: "custom.domain.adguard", + }, + }, + expectedIP: "192.168.1.10", + expectedURL: "my-service.example.com", + expectedPort: 8080, + expectedErr: nil, + expectedNpmOptionsScheme: "http", + expectedNpmOptionsBlockExploits: true, + expectedAdguardHomeOptionsTargetDomain: "custom.domain.adguard", + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ip, url, port, npmOptions, piholeOptions, err := GetValuesFromLabels(tc.container.Labels) + ip, url, port, opts, err := GetValuesFromLabels(tc.container.Labels) assert.Equal(t, tc.expectedIP, ip) assert.Equal(t, tc.expectedURL, url) assert.Equal(t, tc.expectedPort, port) assert.Equal(t, tc.expectedErr, err) if err == nil { - assert.Equal(t, tc.expectedNpmOptionsBlockExploits, npmOptions.BlockExploits) - assert.Equal(t, tc.expectedNpmOptionsCachingEnabled, npmOptions.CachingEnabled) - assert.Equal(t, tc.expectedNpmOptionsScheme, npmOptions.ForwardScheme) - assert.Equal(t, tc.expectedNpmOptionsWebsocketsSupport, npmOptions.AllowWebsocketUpgrade) - assert.Equal(t, tc.expectedPiholeOptionsTargetDomain, piholeOptions.TargetDomain) + assert.NotNil(t, opts) + assert.NotNil(t, opts.NPM) + assert.NotNil(t, opts.Pihole) + assert.NotNil(t, opts.AdguardHome) + assert.Equal(t, tc.expectedNpmOptionsBlockExploits, opts.NPM.BlockExploits) + assert.Equal(t, tc.expectedNpmOptionsCachingEnabled, opts.NPM.CachingEnabled) + assert.Equal(t, tc.expectedNpmOptionsScheme, opts.NPM.ForwardScheme) + assert.Equal(t, tc.expectedNpmOptionsWebsocketsSupport, opts.NPM.AllowWebsocketUpgrade) + assert.Equal(t, tc.expectedPiholeOptionsTargetDomain, opts.Pihole.TargetDomain) + assert.Equal(t, tc.expectedAdguardHomeOptionsTargetDomain, opts.AdguardHome.TargetDomain) + } else { + assert.Nil(t, opts) } }) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 7324bee..6e84c92 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,6 +16,11 @@ var log = logging.GetLogger() type Config struct { Debug bool `env:"DEBUG" envDefault:"false"` + AdguardHomeDisabled bool `env:"ADGUARD_HOME_DISABLED" envDefault:"true"` + AdguardHomeHost string `env:"ADGUARD_HOME_HOST"` + AdguardHomePassword string `env:"ADGUARD_HOME_PASSWORD"` + AdguardHomeUsername string `env:"ADGUARD_HOME_USERNAME"` + NpmHost string `env:"NGINX_PROXY_MANAGER_HOST,notEmpty"` NpmPassword string `env:"NGINX_PROXY_MANAGER_PASSWORD,notEmpty"` NpmUsername string `env:"NGINX_PROXY_MANAGER_USERNAME,notEmpty"` @@ -34,7 +39,7 @@ func GetEnvVars() (*Config, error) { if err := env.ParseWithOptions(&config, env.Options{ OnSet: func(tag string, value any, isDefault bool) { if isDefault { - log.Info(fmt.Sprintf(`env: environment variable "%v" is not set, using default value "%v"`, tag, value)) + log.Info(fmt.Sprintf(`env: environment variable '%v' is not set, using default value '%v'`, tag, value)) } }, }); err != nil { @@ -42,11 +47,15 @@ func GetEnvVars() (*Config, error) { } if !validateRunInterval(config.RunInterval) { - return nil, errors.New(`env: environment variable "RUN_INTERVAL" must be >= 0`) + return nil, errors.New(`env: environment variable 'RUN_INTERVAL' must be >= 0`) } if !config.PiholeDisabled && (config.PiholeHost == "" || config.PiholePassword == "") { - return nil, errors.New(`env: PIHOLE_HOST or PIHOLE_PASSWORD is not set`) + return nil, errors.New(`env: 'PIHOLE_HOST' or 'PIHOLE_PASSWORD' is not set`) + } + + if !config.AdguardHomeDisabled && (config.AdguardHomeHost == "" || config.AdguardHomePassword == "" || config.AdguardHomeUsername == "") { + return nil, errors.New(`env: 'ADGUARD_HOME_HOST', 'ADGUARD_HOME_PASSWORD' or 'ADGUARD_HOME_USERNAME' is not set`) } return &config, nil diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 19ff64d..7c387a4 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -37,14 +37,15 @@ func TestGetEnvVars(t *testing.T) { "RUN_INTERVAL": "5m", }, expectedConfig: &Config{ - NpmHost: "npm.example.com", - NpmPassword: "password", - NpmUsername: "user", - PiholeDisabled: false, - PiholeHost: "pihole.example.com", - PiholePassword: "pihole_pass", - DockerHost: "unix:///var/run/docker.sock", - RunInterval: 5 * time.Minute, + AdguardHomeDisabled: true, + NpmHost: "npm.example.com", + NpmPassword: "password", + NpmUsername: "user", + PiholeDisabled: false, + PiholeHost: "pihole.example.com", + PiholePassword: "pihole_pass", + DockerHost: "unix:///var/run/docker.sock", + RunInterval: 5 * time.Minute, }, expectErr: false, }, @@ -70,13 +71,14 @@ func TestGetEnvVars(t *testing.T) { "PIHOLE_PASSWORD": "pihole_pass", }, expectedConfig: &Config{ - NpmHost: "npm.example.com", - NpmPassword: "password", - NpmUsername: "user", - PiholeDisabled: false, - PiholeHost: "pihole.example.com", - PiholePassword: "pihole_pass", - RunInterval: 1 * time.Hour, // Default value + AdguardHomeDisabled: true, + NpmHost: "npm.example.com", + NpmPassword: "password", + NpmUsername: "user", + PiholeDisabled: false, + PiholeHost: "pihole.example.com", + PiholePassword: "pihole_pass", + RunInterval: 1 * time.Hour, // Default value }, expectErr: false, }, @@ -104,12 +106,13 @@ func TestGetEnvVars(t *testing.T) { "RUN_INTERVAL": "5m", }, expectedConfig: &Config{ - NpmHost: "npm.example.com", - NpmPassword: "password", - NpmUsername: "user", - PiholeDisabled: true, - DockerHost: "unix:///var/run/docker.sock", - RunInterval: 5 * time.Minute, + AdguardHomeDisabled: true, + NpmHost: "npm.example.com", + NpmPassword: "password", + NpmUsername: "user", + PiholeDisabled: true, + DockerHost: "unix:///var/run/docker.sock", + RunInterval: 5 * time.Minute, }, expectErr: false, }, @@ -125,6 +128,42 @@ func TestGetEnvVars(t *testing.T) { expectedConfig: nil, expectErr: true, }, + { + name: "No need to set AdguardHome env vars if AdguardHome is disabled", + envVars: map[string]string{ + "ADGUARD_HOME_DISABLED": "true", + "NGINX_PROXY_MANAGER_HOST": "npm.example.com", + "NGINX_PROXY_MANAGER_PASSWORD": "password", + "NGINX_PROXY_MANAGER_USERNAME": "user", + "PIHOLE_DISABLED": "true", + "DOCKER_HOST": "unix:///var/run/docker.sock", + "RUN_INTERVAL": "5m", + }, + expectedConfig: &Config{ + AdguardHomeDisabled: true, + NpmHost: "npm.example.com", + NpmPassword: "password", + NpmUsername: "user", + PiholeDisabled: true, + DockerHost: "unix:///var/run/docker.sock", + RunInterval: 5 * time.Minute, + }, + expectErr: false, + }, + { + name: "Need to set AdguardHome env vars if AdguardHome is enabled", + envVars: map[string]string{ + "ADGUARD_HOME_DISABLED": "false", + "PIHOLE_DISABLED": "true", + "NGINX_PROXY_MANAGER_HOST": "npm.example.com", + "NGINX_PROXY_MANAGER_PASSWORD": "password", + "NGINX_PROXY_MANAGER_USERNAME": "user", + "DOCKER_HOST": "unix:///var/run/docker.sock", + "RUN_INTERVAL": "5m", + }, + expectedConfig: nil, + expectErr: true, + }, } for _, tc := range testCases { diff --git a/pkg/processor/processor.go b/pkg/processor/processor.go index f84545d..b0c32b0 100644 --- a/pkg/processor/processor.go +++ b/pkg/processor/processor.go @@ -8,6 +8,7 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/events" + "github.com/deepspace2/plugnpin/pkg/clients/adguardhome" "github.com/deepspace2/plugnpin/pkg/clients/docker" "github.com/deepspace2/plugnpin/pkg/clients/npm" "github.com/deepspace2/plugnpin/pkg/clients/pihole" @@ -18,18 +19,20 @@ import ( var log = logging.GetLogger() type Processor struct { - dockerClient *docker.Client - piholeClient *pihole.Client - npmClient *npm.Client - dryRun bool + dockerClient *docker.Client + adguardHomeClient *adguardhome.Client + piholeClient *pihole.Client + npmClient *npm.Client + dryRun bool } -func New(dockerClient *docker.Client, piholeClient *pihole.Client, npmClient *npm.Client, dryRun bool) *Processor { +func New(dockerClient *docker.Client, adguardHomeClient *adguardhome.Client, piholeClient *pihole.Client, npmClient *npm.Client, dryRun bool) *Processor { return &Processor{ - dockerClient: dockerClient, - piholeClient: piholeClient, - npmClient: npmClient, - dryRun: dryRun, + dockerClient: dockerClient, + adguardHomeClient: adguardHomeClient, + piholeClient: piholeClient, + npmClient: npmClient, + dryRun: dryRun, } } @@ -84,7 +87,7 @@ func (p *Processor) RunOnce() { func (p *Processor) preprocessContainer(container container.Summary) { parsedContainerName := docker.GetParsedContainerName(container) - ip, url, port, npmProxyHostOptions, piholeOptions, err := docker.GetValuesFromLabels(container.Labels) + ip, url, port, opts, err := docker.GetValuesFromLabels(container.Labels) if err != nil { switch err.(type) { case *errors.NonExistingLabelsError: @@ -94,7 +97,7 @@ func (p *Processor) preprocessContainer(container container.Summary) { } return } - p.processContainer(docker.ContainerEvent.Start, parsedContainerName, ip, url, port, *piholeOptions, *npmProxyHostOptions) + p.processContainer(docker.ContainerEvent.Start, parsedContainerName, ip, url, port, opts) } func (p *Processor) handleDockerEvent(event events.Message) { @@ -104,7 +107,7 @@ func (p *Processor) handleDockerEvent(event events.Message) { return } - ip, url, port, npmProxyHostOptions, piholeOptions, err := docker.GetValuesFromLabels(event.Actor.Attributes) + ip, url, port, opts, err := docker.GetValuesFromLabels(event.Actor.Attributes) if err != nil { switch err.(type) { case *errors.NonExistingLabelsError: @@ -116,7 +119,31 @@ func (p *Processor) handleDockerEvent(event events.Message) { return } containerEvent, _ := docker.ContainerEvent.ParseString(string(event.Action)) - p.processContainer(containerEvent, containerName, ip, url, port, *piholeOptions, *npmProxyHostOptions) + p.processContainer(containerEvent, containerName, ip, url, port, opts) +} + +func (p *Processor) handleAdguardHome(containerEvent docker.EventType, containerName, url, ip string, adguardHomeOptions adguardhome.AdguardHomeOptions) { + if p.adguardHomeClient != nil { + if adguardHomeOptions.TargetDomain != "" { + // quick "workaround" for the fact that adguard unifies "local DNS records" and "CNAME records" + ip = adguardHomeOptions.TargetDomain + } + + switch containerEvent { + case docker.ContainerEvent.Start: + log.Info("Adding a DNS rewrite to AdGuard Home", "container", containerName, "domain", url, "answer", ip) + err := p.adguardHomeClient.AddDnsRewrite(url, ip) + if err != nil { + log.Error("Failed to add a DNS rewrite to AdGuard Home", "container", containerName, "domain", url, "answer", ip, "error", err) + } + case docker.ContainerEvent.Die: + log.Info("Deleting DNS rewrite from AdGuard Home", "container", containerName, "domain", url) + err := p.adguardHomeClient.DeleteDnsRewrite(url, ip) + if err != nil { + log.Error("Failed to delete DNS rewrite from AdGuard Home", "container", containerName, "domain", url, "error", err) + } + } + } } func (p *Processor) handlePiHole(containerEvent docker.EventType, containerName, url, ip string, piholeOptions pihole.PiHoleOptions) { @@ -197,7 +224,7 @@ func (p *Processor) handleNpm(containerEvent docker.EventType, containerName, ur } } -func (p *Processor) processContainer(containerEvent docker.EventType, containerName, ip, url string, port int, piholeOptions pihole.PiHoleOptions, npmProxyHostOptions npm.NpmProxyHostOptions) { +func (p *Processor) processContainer(containerEvent docker.EventType, containerName, ip, url string, port int, opts *docker.ClientOptions) { msg := "Handling container" if p.dryRun { @@ -210,7 +237,14 @@ func (p *Processor) processContainer(containerEvent docker.EventType, containerN if p.npmClient != nil { npmHost := p.npmClient.GetIP() - p.handlePiHole(containerEvent, containerName, url, npmHost, piholeOptions) - p.handleNpm(containerEvent, containerName, url, ip, port, npmProxyHostOptions) + if opts.AdguardHome != nil { + p.handleAdguardHome(containerEvent, containerName, url, npmHost, *opts.AdguardHome) + } + if opts.Pihole != nil { + p.handlePiHole(containerEvent, containerName, url, npmHost, *opts.Pihole) + } + if opts.NPM != nil { + p.handleNpm(containerEvent, containerName, url, ip, port, *opts.NPM) + } } }