Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**

Expand All @@ -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
Expand Down
36 changes: 30 additions & 6 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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. <br> **Important:** It is recommended to create a new non-admin user with only the "Proxy Hosts - Manage" permission. | |
Expand All @@ -52,6 +68,7 @@ See [Per Container Configuration ➔ Pi-Hole](#targetDomainLabel).

| Variable {: style="width:35%" } | Description | Default {: style="width:10%" } |
|---|---|---|
| <div id="adguardHomeDisabledEnvVar"><a name="adguardHomeDisabledEnvVar"></a>`ADGUARD_HOME_DISABLED`</div> | 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* |
| <div id="piHoleDisabledEnvVar"><a name="piholeDisabledEnvVar"></a>`PIHOLE_DISABLED`</div> | Set to `true` to disable Pi-Hole functionality | `false` |
Expand All @@ -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%"} |
|---|---|---|
| <div id="adguardHomeTargetDomainLabel"><a name="adguardHomeTargetDomainLabel"></a>`plugNPiN.adguardHomeOptions.targetDomain`</div> | If provided, a CNAME DNS Rewrite will be created | |

#### Nginx Proxy Manager

Use the following labels to configure Nginx Proxy Manager entries
Expand All @@ -87,7 +110,8 @@ Use the following labels to configure Nginx Proxy Manager entries

| Label {: style="width:35%"} | Description | Default {: style="width:10%"} |
|---|---|---|
| <div id="targetDomainLabel"><a name="targetDomainLabel"></a>`plugNPiN.piholeOptions.targetDomain`</div> | If provided, a CNAME record will be created **instead** of a DNS record | |
| <div id="piholeTargetDomainLabel"><a name="piholeTargetDomainLabel"></a>`plugNPiN.piholeOptions.targetDomain`</div> | If provided, a CNAME record will be created **instead** of a DNS record | |


## Usage

Expand Down
8 changes: 7 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand All @@ -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 {
Expand All @@ -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")
Expand Down
96 changes: 96 additions & 0 deletions pkg/clients/adguardhome/adguardhome.go
Original file line number Diff line number Diff line change
@@ -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
}
139 changes: 139 additions & 0 deletions pkg/clients/adguardhome/adguardhome_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading