diff --git a/.example.env b/.example.env index b2ddc86..dc65dd1 100644 --- a/.example.env +++ b/.example.env @@ -1,4 +1,5 @@ DOCKER_HOST= # optional but highly recommended, use a docker socket proxy instead of mounting the docker socket +DOCKER_HOSTS= # optional, comma-separated list of docker hosts NGINX_PROXY_MANAGER_HOST= # required NGINX_PROXY_MANAGER_USERNAME= # required NGINX_PROXY_MANAGER_PASSWORD= # required diff --git a/README.md b/README.md index bdf288d..430fa11 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,11 @@ as local DNS/CNAME records in **Pi-Hole** (or DNS Rewrites in **AdGuard Home**) ## Key Features -- Automatic Docker container detection. -- 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. +- Automatic Docker container detection (monitoring multiple hosts is supported) +- 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 **Pi-Hole's and AdGuard Home's functionality can be toggled individually. By default Pi-Hole is enabled and AdGuard Home is disabled.** @@ -78,7 +78,7 @@ services: restart: unless-stopped ``` -#### Not Recommended: Mounting the Docker Socket +#### Not Recommended: Directly mounting the Docker Socket ```yaml services: @@ -92,7 +92,7 @@ services: - PIHOLE_HOST=... - PIHOLE_PASSWORD=... volumes: - - /var/run/docker.sock:/var/run/docker.sock + - /var/run/docker.sock:/var/run/docker.sock:ro restart: unless-stopped ``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..8f73f2d --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,62 @@ +# Configuration + +## Environment Variables + +### Required + +| Variable {: style="width:35%" } | Description | Notes | +|---|---|---| +| `ADGUARD_HOME_HOST` | The URL of your AdGuard Home instance | Only required if `ADGUARD_HOME_DISABLED` is set to `false` | +| `ADGUARD_HOME_USERNAME` | Your AdGuard Home username | Only required if `ADGUARD_HOME_DISABLED` is set to `false` | +| `ADGUARD_HOME_PASSWORD` | Your AdGuard Home password | Only required if `ADGUARD_HOME_DISABLED` 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. | | +| `PIHOLE_HOST` | The URL of your Pi-Hole instance. | Only required if `PIHOLE_DISABLED` is set to `false` | +| `PIHOLE_PASSWORD` | Your Pi-Hole password.
**Important:** It is recommended to create an 'application password' rather than using your actual admin password. | Only required if `PIHOLE_DISABLED` is set to `false` | + +### Optional + +| 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* | +| `DOCKER_HOSTS` | Comma-separated list of multiple docker hosts to monitor, with an empty string meaning the default local host.
For example `DOCKER_HOSTS=,tcp://192.168.0.101:2375` | `""` | +| `PIHOLE_DISABLED` | Set to `true` to disable Pi-Hole functionality | `false` | +| `RUN_INTERVAL` | The interval at which to scan for new containers, in Go's [`time.ParseDuration`](){: target="_blank" } format. Set to `0` to run once and exit. | `1h` | +| `TZ` | Customise the timezone. | *None* | + +## Per Container Configuration + +Use the following labels on your containers to enable specific features + +### 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 + + +| Label {: style="width:30%"} | Description | Default {: style="width:10%"} | Notes | +|---|---|---|---| +| `plugNPiN.npmOptions.advancedConfig` | Advanced nginx configuration (referred to as `Custom Nginx Configuration` in NPM UI) | | If using a docker compose file make sure to use `|` so new lines will be respected, for example:
labels:
- plugNPiN.ip=192.168.0.100:8000
- plugNPiN.url=service.home
- \|
plugNPiN.npmOptions.advancedConfig=location / {
allow 192.168.0.1/15;
deny all;
}
| +| `plugNPiN.npmOptions.blockExploits` | Enables or disables the "Block Common Exploits" option on the proxy host. Set to `true` or `false` | `true` | | +| `plugNPiN.npmOptions.cachingEnabled` | Enables or disables the "Cache Assets" option on the proxy host. Set to `true` or `false` | `false` | | +| `plugNPiN.npmOptions.certificateName` | Certificate to use for this host. Must already exist on the NPM instance | | | +| `plugNPiN.npmOptions.forceSsl` | Force SSL | `false` | | +| `plugNPiN.npmOptions.http2Support` | Enable HTTP/2 Support | `false` | | +| `plugNPiN.npmOptions.hstsEnabled` | Enable HSTS | `false` | | +| `plugNPiN.npmOptions.hstsSubdomains` | Enable HSTS Subdomains | `false` | | +| `plugNPiN.npmOptions.scheme` | The scheme used to forward traffic to the container. Can be `http` or `https` | `http` | | +| `plugNPiN.npmOptions.websocketsSupport` | Enables or disables the "Allow Websocket Upgrade" option on the proxy host. Set to `true` or `false` | `false` | | + +### Pi-Hole + +| 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 | | + +*[NPM]: Nginx Proxy Manager diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..8429843 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,3 @@ +# Contributing + +Contributions are very welcome! If you have a feature request, bug report, or want to contribute yourself, please feel free to open an [issue](https://github.com/DeepSpace2/PlugNPiN/issues/new){: target="\_blank" } or submit a pull request. diff --git a/docs/index.md b/docs/index.md index 37d3961..2139d08 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,11 +8,11 @@ **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](#piholeTargetDomainLabel) records in **Pi-Hole** (or DNS Rewrites in **AdGuard Home**) 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**. **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). +See [Optional Environment Variables](./configuration.md#optional). ## How It Works @@ -38,138 +38,10 @@ When a container is processed in either mode, PlugNPiN will: To create a DNS Rewrite as a CNAME, set the `plugNPiN.adguardHomeOptions.targetDomain` label. -See [Per Container Configuration ➔ AdGuard Home](#adguardHomeTargetDomainLabel). +See [Per Container Configuration ➔ AdGuard Home](./configuration.md#adguard-home). #### 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 - -### Environment Variables - -#### Required - -| 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. | | -| `PIHOLE_HOST` | The URL of your Pi-Hole instance. | Only required if [`PIHOLE_DISABLED`](#piHoleDisabledEnvVar) is set to `false` | -| `PIHOLE_PASSWORD` | Your Pi-Hole password.
**Important:** It is recommended to create an 'application password' rather than using your actual admin password. | Only required if [`PIHOLE_DISABLED`](#piHoleDisabledEnvVar) is set to `false` | - -#### Optional - -| 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` | -| `RUN_INTERVAL` | The interval at which to scan for new containers, in Go's [`time.ParseDuration`](){: target="_blank" } format. Set to `0` to run once and exit. | `1h` | -| `TZ` | Customise the timezone. | *None* | - -### Flags - -| Flag {: style="width:35%" } | Description | -|---|---| -| `--dry-run`, `-d` | Simulates the process of adding DNS/CNAME records and proxy hosts without making any actual changes to Pi-Hole or Nginx Proxy Manager. | - -### 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 - -| Label {: style="width:30%"} | Description | Default {: style="width:10%"} | Notes | -|---|---|---|---| -| `plugNPiN.npmOptions.advancedConfig` | Advanced nginx configuration (referred to as `Custom Nginx Configuration` in NPM UI) | | If using a docker compose file make sure to use `|` so new lines will be respected, for example:
labels:
- plugNPiN.ip=192.168.0.100:8000
- plugNPiN.url=service.home
- \|
plugNPiN.npmOptions.advancedConfig=location / {
allow 192.168.0.1/15;
deny all;
}
| -| `plugNPiN.npmOptions.blockExploits` | Enables or disables the "Block Common Exploits" option on the proxy host. Set to `true` or `false` | `true` | | -| `plugNPiN.npmOptions.cachingEnabled` | Enables or disables the "Cache Assets" option on the proxy host. Set to `true` or `false` | `false` | | -| `plugNPiN.npmOptions.certificateName` | Certificate to use for this host. Must already exist on the NPM instance | | | -| `plugNPiN.npmOptions.forceSsl` | Force SSL | `false` | | -| `plugNPiN.npmOptions.http2Support` | Enable HTTP/2 Support | `false` | | -| `plugNPiN.npmOptions.hstsEnabled` | Enable HSTS | `false` | | -| `plugNPiN.npmOptions.hstsSubdomains` | Enable HSTS Subdomains | `false` | | -| `plugNPiN.npmOptions.scheme` | The scheme used to forward traffic to the container. Can be `http` or `https` | `http` | | -| `plugNPiN.npmOptions.websocketsSupport` | Enables or disables the "Allow Websocket Upgrade" option on the proxy host. Set to `true` or `false` | `false` | | - -#### Pi-Hole - -| 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 | | - - -## Usage - -### Docker Compose - -It is **highly recommended** to use a Docker socket proxy to avoid giving the container direct access to the Docker daemon. This improves security by limiting the container's privileges. - -#### Recommended: Using a Docker Socket Proxy - -```yaml -services: - socket-proxy: - image: lscr.io/linuxserver/socket-proxy:latest - container_name: socket-proxy - environment: - # Allow access to the container list - - CONTAINERS=1 - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - read_only: true - tmpfs: - - /run - - plugnpin: - image: ghcr.io/deepspace2/plugnpin:latest - container_name: plugnpin - depends_on: - - socket-proxy - environment: - - DOCKER_HOST=tcp://socket-proxy:2375 - - NGINX_PROXY_MANAGER_HOST=... - - NGINX_PROXY_MANAGER_USERNAME=... - - NGINX_PROXY_MANAGER_PASSWORD=... - - PIHOLE_HOST=... - - PIHOLE_PASSWORD=... - restart: unless-stopped -``` - -#### Not Recommended: Mounting the Docker Socket - -```yaml -services: - plugnpin: - image: ghcr.io/deepspace2/plugnpin:latest - container_name: plugnpin - environment: - - NGINX_PROXY_MANAGER_HOST=... - - NGINX_PROXY_MANAGER_USERNAME=... - - NGINX_PROXY_MANAGER_PASSWORD=... - - PIHOLE_HOST=... - - PIHOLE_PASSWORD=... - volumes: - - /var/run/docker.sock:/var/run/docker.sock - restart: unless-stopped -``` - -## Contributing - -Contributions are very welcome! If you have a feature request, bug report, or want to contribute yourself, please feel free to open an issue or submit a pull request. - -*[NPM]: Nginx Proxy Manager +See [Per Container Configuration ➔ Pi-Hole](./configuration.md#pi-hole). diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..1e57fcb --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,60 @@ +# Usage + +## CLI Flags + +| Flag {: style="width:35%" } | Description | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `--dry-run`, `-d` | Simulates the process of adding DNS/CNAME records and proxy hosts without making any actual changes to Pi-Hole or Nginx Proxy Manager. | + +## Docker Compose + +It is **highly recommended** to use a Docker socket proxy to avoid giving the container direct access to the Docker daemon. This improves security by limiting the container's privileges. + +=== "Recommended: Using a Docker Socket Proxy" + + ```yaml + services: + socket-proxy: + image: lscr.io/linuxserver/socket-proxy:latest + container_name: socket-proxy + environment: + # Allow access to the container list + - CONTAINERS=1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + read_only: true + tmpfs: + - /run + + plugnpin: + image: ghcr.io/deepspace2/plugnpin:latest + container_name: plugnpin + depends_on: + - socket-proxy + environment: + - DOCKER_HOST=tcp://socket-proxy:2375 + - NGINX_PROXY_MANAGER_HOST=... + - NGINX_PROXY_MANAGER_USERNAME=... + - NGINX_PROXY_MANAGER_PASSWORD=... + - PIHOLE_HOST=... + - PIHOLE_PASSWORD=... + restart: unless-stopped + ``` + +=== "Not Recommended: Mounting the Docker Socket" + + ```yaml + services: + plugnpin: + image: ghcr.io/deepspace2/plugnpin:latest + container_name: plugnpin + environment: + - NGINX_PROXY_MANAGER_HOST=... + - NGINX_PROXY_MANAGER_USERNAME=... + - NGINX_PROXY_MANAGER_PASSWORD=... + - PIHOLE_HOST=... + - PIHOLE_PASSWORD... + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + ``` diff --git a/e2e_tests/e2e_test.go b/e2e_tests/e2e_test.go index f10006b..97fbbe3 100644 --- a/e2e_tests/e2e_test.go +++ b/e2e_tests/e2e_test.go @@ -128,7 +128,7 @@ func startRequiredContainers(t *testing.T, ctx context.Context, dockerCli *docke } func setClients(t *testing.T, containers []Container) (*docker.Client, *pihole.Client, *npm.Client, *adguardhome.Client) { - dockerClient, err := docker.NewClient() + dockerClient, err := docker.NewClient("") if err != nil { t.Fatalf("Failed to create docker client: %v", err) } @@ -308,7 +308,7 @@ func TestE2E(t *testing.T) { time.Sleep(2 * time.Second) proc := processor.New( - dockerClient, + map[string]*docker.Client{dockerClient.Host: dockerClient}, adguardHomeClient, piholeClient, npmClient, diff --git a/main.go b/main.go index 85dd6f0..df2c26b 100644 --- a/main.go +++ b/main.go @@ -9,10 +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" + "github.com/deepspace2/plugnpin/pkg/clients" "github.com/deepspace2/plugnpin/pkg/config" "github.com/deepspace2/plugnpin/pkg/logging" "github.com/deepspace2/plugnpin/pkg/processor" @@ -20,18 +17,6 @@ import ( var log = logging.GetLogger() -func shutdown(cancelCtx context.CancelFunc, wg *sync.WaitGroup) { - shutdownChan := make(chan os.Signal, 1) - signal.Notify(shutdownChan, syscall.SIGINT, syscall.SIGTERM) - - <-shutdownChan - - log.Info("Shutdown signal received, exiting gracefully.") - cancelCtx() - wg.Wait() - log.Info("Shutdown complete.") -} - func main() { cliFlags := cli.ParseFlags() @@ -51,40 +36,12 @@ func main() { log.Info(fmt.Sprintf("Will run every %v", conf.RunInterval)) } - var adguardHomeClient *adguardhome.Client - var piholeClient *pihole.Client - var npmClient *npm.Client - - if !cliFlags.DryRun { - if !conf.PiholeDisabled { - piholeClient = pihole.NewClient(conf.PiholeHost) - err = piholeClient.Login(conf.PiholePassword) - if err != nil { - log.Error("Failed to login to Pi-Hole", "error", err) - os.Exit(1) - } - } - - 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 { - log.Error("Failed to login to Nginx Proxy Manager", "error", err) - os.Exit(1) - } - } - - dockerClient, err := docker.NewClient() + dockerClients, adguardHomeClient, piholeClient, npmClient, err := clients.GetClients(cliFlags, conf) if err != nil { - log.Error("Failed to create docker client", "error", err) os.Exit(1) } - defer dockerClient.Close() - proc := processor.New(dockerClient, adguardHomeClient, piholeClient, npmClient, cliFlags.DryRun) + proc := processor.New(dockerClients, adguardHomeClient, piholeClient, npmClient, cliFlags.DryRun) if conf.RunInterval == 0 { log.Info("RUN_INTERVAL is 0, will run once") @@ -109,3 +66,15 @@ func main() { shutdown(cancel, &wg) } + +func shutdown(cancelCtx context.CancelFunc, wg *sync.WaitGroup) { + shutdownChan := make(chan os.Signal, 1) + signal.Notify(shutdownChan, syscall.SIGINT, syscall.SIGTERM) + + <-shutdownChan + + log.Info("Shutdown signal received, exiting gracefully.") + cancelCtx() + wg.Wait() + log.Info("Shutdown complete.") +} diff --git a/mkdocs.yml b/mkdocs.yml index bc310e1..0c69294 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,10 +3,11 @@ site_name: PlugNPiN site_url: https://deepspace2.github.io/plugnpin repo_url: https://github.com/deepspace2/plugnpin -extra_css: - - css/anchor-jump-highlight.css -extra_javascript: - - js/anchor-jump-highlight.js +nav: + - About: index.md + - Configuration: configuration.md + - Usage: usage.md + - Contributing: contributing.md theme: name: material custom_dir: docs/theme/overrides @@ -15,6 +16,9 @@ theme: features: - content.code.copy - content.tooltips + - navigation.footer + - navigation.instant + - navigation.instant.progress - navigation.top - search.highlight - search.share @@ -45,14 +49,12 @@ extra: alias: true provider: mike markdown_extensions: - - admonition - abbr - attr_list - - pymdownx.details - - pymdownx.inlinehilite - pymdownx.highlight: anchor_linenums: true line_spans: __span pygments_lang_class: true - - pymdownx.snippets + - pymdownx.tabbed: + alternate_style: true - pymdownx.superfences diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index 57e6970..b39eb41 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -4,14 +4,14 @@ import ( flag "github.com/spf13/pflag" ) -type f struct { +type Flags struct { DryRun bool } -var flags f = f{} +var flags = Flags{} -func ParseFlags() f { - flag.BoolVarP(&flags.DryRun, "dry-run", "d", false, "Simulates the process of adding DNS records and proxy hosts without making any actual changes to Pi-Hole or Nginx Proxy Manager.") +func ParseFlags() Flags { + flag.BoolVarP(&flags.DryRun, "dry-run", "d", false, "Simulates the process of adding DNS records and proxy hosts without applying changes to Pi-Hole, AdGuard Home or Nginx Proxy Manager.") flag.Parse() return flags } diff --git a/pkg/clients/adguardhome/adguardhome.go b/pkg/clients/adguardhome/adguardhome.go index fdde405..4c67214 100644 --- a/pkg/clients/adguardhome/adguardhome.go +++ b/pkg/clients/adguardhome/adguardhome.go @@ -7,7 +7,7 @@ import ( "fmt" "net/http" - "github.com/deepspace2/plugnpin/pkg/clients" + "github.com/deepspace2/plugnpin/pkg/clients/common" "github.com/deepspace2/plugnpin/pkg/logging" ) @@ -34,7 +34,7 @@ func NewClient(baseURL, username, password string) *Client { } func (ad *Client) GetDnsRewrites() (DnsRewrites, error) { - dnsRewritesResponseString, _, err := clients.Get(&ad.Client, ad.baseURL+"/rewrite/list", headers) + dnsRewritesResponseString, _, err := common.Get(&ad.Client, ad.baseURL+"/rewrite/list", headers) if err != nil { return nil, err } @@ -65,7 +65,7 @@ func (ad *Client) AddDnsRewrite(domain, ip string) error { return err } payloadString := string(payload) - _, statusCode, err := clients.Post(&ad.Client, ad.baseURL+"/rewrite/add", headers, &payloadString) + _, statusCode, err := common.Post(&ad.Client, ad.baseURL+"/rewrite/add", headers, &payloadString) if err != nil { return err } @@ -83,7 +83,7 @@ func (ad *Client) DeleteDnsRewrite(domain, ip string) error { return err } payloadString := string(payload) - _, statusCode, err := clients.Post(&ad.Client, ad.baseURL+"/rewrite/delete", headers, &payloadString) + _, statusCode, err := common.Post(&ad.Client, ad.baseURL+"/rewrite/delete", headers, &payloadString) if err != nil { return err } diff --git a/pkg/clients/clients.go b/pkg/clients/clients.go new file mode 100644 index 0000000..79668de --- /dev/null +++ b/pkg/clients/clients.go @@ -0,0 +1,56 @@ +package clients + +import ( + "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" + "github.com/deepspace2/plugnpin/pkg/config" + "github.com/deepspace2/plugnpin/pkg/logging" +) + +var log = logging.GetLogger() + +func GetClients(cliFlags cli.Flags, conf *config.Config) (map[string]*docker.Client, *adguardhome.Client, *pihole.Client, *npm.Client, error) { + var adguardHomeClient *adguardhome.Client + var piholeClient *pihole.Client + var npmClient *npm.Client + + if !cliFlags.DryRun { + if !conf.PiholeDisabled { + piholeClient = pihole.NewClient(conf.PiholeHost) + err := piholeClient.Login(conf.PiholePassword) + if err != nil { + log.Error("Failed to login to Pi-Hole", "error", err) + return nil, nil, nil, nil, err + } + } + + 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 { + log.Error("Failed to login to Nginx Proxy Manager", "error", err) + return nil, nil, nil, nil, err + } + } + + dockerClients := make(map[string]*docker.Client) + if len(conf.DockerHosts) == 0 { + conf.DockerHosts = append(conf.DockerHosts, conf.DockerHost) + } + for _, host := range conf.DockerHosts { + dockerClient, err := docker.NewClient(host) + if err != nil { + log.Error("Failed to create docker client", "host", host, "error", err) + continue + } + dockerClients[dockerClient.Host] = dockerClient + } + + return dockerClients, adguardHomeClient, piholeClient, npmClient, nil +} diff --git a/pkg/clients/common.go b/pkg/clients/common/common.go similarity index 99% rename from pkg/clients/common.go rename to pkg/clients/common/common.go index afa7702..7a73e74 100644 --- a/pkg/clients/common.go +++ b/pkg/clients/common/common.go @@ -1,4 +1,4 @@ -package clients +package common import ( "io" diff --git a/pkg/clients/docker/docker.go b/pkg/clients/docker/docker.go index 966da5d..85337fc 100644 --- a/pkg/clients/docker/docker.go +++ b/pkg/clients/docker/docker.go @@ -46,13 +46,15 @@ const ( var labels []string = []string{IpLabel, UrlLabel} -type Client struct { - *dockerSdk.Client -} - -func NewClient() (*Client, error) { - client, err := dockerSdk.New(context.Background()) - return &Client{client}, err +func NewClient(host string) (*Client, error) { + client, err := dockerSdk.New(context.Background(), dockerSdk.WithDockerHost(host)) + var displayHost string + if host == "" { + displayHost = "local" + } else { + displayHost = host + } + return &Client{Client: client, Host: host, DisplayHost: displayHost}, err } func (d *Client) GetRelevantContainers() ([]container.Summary, error) { @@ -61,7 +63,7 @@ func (d *Client) GetRelevantContainers() ([]container.Summary, error) { f.Add("label", label) } - log.Info(fmt.Sprintf("Getting containers with labels: %v", strings.Join(labels, ", "))) + log.Info(fmt.Sprintf("Getting containers with labels: %v", strings.Join(labels, ", ")), "host", d.DisplayHost) return d.ContainerList( context.Background(), diff --git a/pkg/clients/docker/events.go b/pkg/clients/docker/events.go index fdd3585..ef17291 100644 --- a/pkg/clients/docker/events.go +++ b/pkg/clients/docker/events.go @@ -5,22 +5,17 @@ import ( "github.com/docker/docker/api/types/events" "github.com/docker/docker/api/types/filters" - dockerClient "github.com/docker/docker/client" ) -func Listen(ctx context.Context, handler func(events.Message)) error { - c, err := dockerClient.NewClientWithOpts(dockerClient.WithHostFromEnv()) - if err != nil { - return err - } - defer c.Close() - +func Listen(ctx context.Context, dockerClient *Client, handler func(events.Message)) error { f := filters.NewArgs() f.Add("type", "container") f.Add("event", ContainerEvent.Start.String()) f.Add("event", ContainerEvent.Die.String()) - log.Info("Listening for Docker events...") + log.Info("Listening for Docker events...", "host", dockerClient.DisplayHost) + + c, _ := dockerClient.Client.Client() messages, errs := c.Events(ctx, events.ListOptions{ Filters: f, @@ -29,13 +24,13 @@ func Listen(ctx context.Context, handler func(events.Message)) error { for { select { case <-ctx.Done(): - log.Info("Stopping stream of Docker events") + log.Info("Stopping stream of Docker events", "host", dockerClient.DisplayHost) return ctx.Err() case event := <-messages: handler(event) case err := <-errs: if err != nil { - log.Error("Failed to receive event", "error", err) + log.Error("Failed to receive event", "host", dockerClient.DisplayHost, "error", err) } return err } diff --git a/pkg/clients/docker/types.go b/pkg/clients/docker/types.go index 6493fe8..b3902c6 100644 --- a/pkg/clients/docker/types.go +++ b/pkg/clients/docker/types.go @@ -1,10 +1,18 @@ package docker +import dockerSdk "github.com/docker/go-sdk/client" + const ( start = "start" die = "die" ) +type Client struct { + *dockerSdk.Client + DisplayHost string + Host string +} + type EventType string type ContainerEventEnum struct { diff --git a/pkg/clients/npm/npm.go b/pkg/clients/npm/npm.go index f0865de..d598026 100644 --- a/pkg/clients/npm/npm.go +++ b/pkg/clients/npm/npm.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "github.com/deepspace2/plugnpin/pkg/clients" + "github.com/deepspace2/plugnpin/pkg/clients/common" "github.com/deepspace2/plugnpin/pkg/logging" ) @@ -58,7 +58,7 @@ func (n *Client) Login() error { return err } payloadString := string(payloadBytes) - loginResponseString, statusCode, err := clients.Post(&n.Client, n.baseURL+"/tokens", n.headers, &payloadString) + loginResponseString, statusCode, err := common.Post(&n.Client, n.baseURL+"/tokens", n.headers, &payloadString) if err != nil { return err } @@ -124,11 +124,11 @@ func (n *Client) makeRequest(method, url string, payload *string) (string, int, doRequest := func() (string, int, error) { switch method { case http.MethodGet: - return clients.Get(&n.Client, url, n.headers) + return common.Get(&n.Client, url, n.headers) case http.MethodPost: - return clients.Post(&n.Client, url, n.headers, payload) + return common.Post(&n.Client, url, n.headers, payload) case http.MethodDelete: - return clients.Delete(&n.Client, url, n.headers) + return common.Delete(&n.Client, url, n.headers) default: return "", 0, fmt.Errorf("unsupported http method: %s", method) } diff --git a/pkg/clients/pihole/pihole.go b/pkg/clients/pihole/pihole.go index 177db03..012b835 100644 --- a/pkg/clients/pihole/pihole.go +++ b/pkg/clients/pihole/pihole.go @@ -8,7 +8,7 @@ import ( "os" "strings" - "github.com/deepspace2/plugnpin/pkg/clients" + "github.com/deepspace2/plugnpin/pkg/clients/common" "github.com/deepspace2/plugnpin/pkg/logging" ) @@ -36,7 +36,7 @@ func NewClient(baseURL string) *Client { func (p *Client) Login(password string) error { loginPayload := fmt.Sprintf(`{"password": "%v"}`, password) - loginResponseString, statusCode, err := clients.Post(&p.Client, p.baseURL+"/auth", headers, &loginPayload) + loginResponseString, statusCode, err := common.Post(&p.Client, p.baseURL+"/auth", headers, &loginPayload) if err != nil { return err } @@ -73,7 +73,7 @@ func (p *Client) GetDnsRecords() (DnsRecords, error) { os.Exit(1) } headers["X-FTL-SID"] = p.sid - configResponseString, _, err := clients.Get(&p.Client, p.baseURL+"/config", headers) + configResponseString, _, err := common.Get(&p.Client, p.baseURL+"/config", headers) if err != nil { return nil, err } @@ -123,7 +123,7 @@ func (p *Client) AddDnsRecord(domain, ip string) error { os.Exit(1) } headers["X-FTL-SID"] = p.sid - resp, statusCode, err := clients.Patch(&p.Client, p.baseURL+"/config", headers, string(payloadString)) + resp, statusCode, err := common.Patch(&p.Client, p.baseURL+"/config", headers, string(payloadString)) if err != nil { return err } @@ -172,7 +172,7 @@ func (p *Client) DeleteDnsRecord(domain string) error { os.Exit(1) } headers["X-FTL-SID"] = p.sid - resp, statusCode, err := clients.Patch(&p.Client, p.baseURL+"/config", headers, string(payloadString)) + resp, statusCode, err := common.Patch(&p.Client, p.baseURL+"/config", headers, string(payloadString)) if err != nil { return err } @@ -210,7 +210,7 @@ func (p *Client) getCNameRecords() (CNameRecords, error) { os.Exit(1) } headers["X-FTL-SID"] = p.sid - configResponseString, _, err := clients.Get(&p.Client, p.baseURL+"/config", headers) + configResponseString, _, err := common.Get(&p.Client, p.baseURL+"/config", headers) if err != nil { return nil, err } @@ -260,7 +260,7 @@ func (p *Client) AddCNameRecord(domain, target string) error { os.Exit(1) } headers["X-FTL-SID"] = p.sid - resp, statusCode, err := clients.Patch(&p.Client, p.baseURL+"/config", headers, string(payloadString)) + resp, statusCode, err := common.Patch(&p.Client, p.baseURL+"/config", headers, string(payloadString)) if err != nil { return err } @@ -309,7 +309,7 @@ func (p *Client) DeleteCNameRecord(domain, target string) error { os.Exit(1) } headers["X-FTL-SID"] = p.sid - resp, statusCode, err := clients.Patch(&p.Client, p.baseURL+"/config", headers, string(payloadString)) + resp, statusCode, err := common.Patch(&p.Client, p.baseURL+"/config", headers, string(payloadString)) if err != nil { return err } diff --git a/pkg/config/config.go b/pkg/config/config.go index 6e84c92..668a4a1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -30,6 +30,7 @@ type Config struct { PiholePassword string `env:"PIHOLE_PASSWORD"` DockerHost string `env:"DOCKER_HOST"` + DockerHosts []string `env:"DOCKER_HOSTS"` RunInterval time.Duration `env:"RUN_INTERVAL" envDefault:"1h"` } diff --git a/pkg/processor/processor.go b/pkg/processor/processor.go index b0c32b0..58a0706 100644 --- a/pkg/processor/processor.go +++ b/pkg/processor/processor.go @@ -19,16 +19,16 @@ import ( var log = logging.GetLogger() type Processor struct { - dockerClient *docker.Client + dockerClients map[string]*docker.Client adguardHomeClient *adguardhome.Client piholeClient *pihole.Client npmClient *npm.Client dryRun bool } -func New(dockerClient *docker.Client, adguardHomeClient *adguardhome.Client, piholeClient *pihole.Client, npmClient *npm.Client, dryRun bool) *Processor { +func New(dockerClients map[string]*docker.Client, adguardHomeClient *adguardhome.Client, piholeClient *pihole.Client, npmClient *npm.Client, dryRun bool) *Processor { return &Processor{ - dockerClient: dockerClient, + dockerClients: dockerClients, adguardHomeClient: adguardHomeClient, piholeClient: piholeClient, npmClient: npmClient, @@ -60,31 +60,37 @@ func (p *Processor) RunScheduled(ctx context.Context, interval time.Duration) { } func (p *Processor) ListenForEvents(ctx context.Context) { - err := docker.Listen(ctx, func(event events.Message) { - p.handleDockerEvent(event) - }) - - if err != nil && err != context.Canceled { - log.Error("Docker event listener stopped", "error", err) + for _, client := range p.dockerClients { + go func(c *docker.Client) { + log.Info("Starting event listener", "host", c.DisplayHost) + err := docker.Listen(ctx, c, func(event events.Message) { + p.handleDockerEvent(event, c.DisplayHost) + }) + if err != nil && err != context.Canceled { + log.Error("Docker event listener stopped", "host", c.DisplayHost, "error", err) + } + }(client) } } func (p *Processor) RunOnce() { - containers, err := p.dockerClient.GetRelevantContainers() - if err != nil { - log.Error("Failed to get containers", "error", err) - return - } + for _, dockerClient := range p.dockerClients { + containers, err := dockerClient.GetRelevantContainers() + if err != nil { + log.Error("Failed to get containers", "host", dockerClient.DisplayHost, "error", err) + continue + } - log.Info(fmt.Sprintf("Found %v containers", len(containers))) + log.Info(fmt.Sprintf("Found %v containers", len(containers)), "host", dockerClient.DisplayHost) - for _, container := range containers { - p.preprocessContainer(container) + for _, container := range containers { + p.preprocessContainer(container, dockerClient.DisplayHost) + } } log.Info("Done") } -func (p *Processor) preprocessContainer(container container.Summary) { +func (p *Processor) preprocessContainer(container container.Summary, host string) { parsedContainerName := docker.GetParsedContainerName(container) ip, url, port, opts, err := docker.GetValuesFromLabels(container.Labels) @@ -97,13 +103,13 @@ func (p *Processor) preprocessContainer(container container.Summary) { } return } - p.processContainer(docker.ContainerEvent.Start, parsedContainerName, ip, url, port, opts) + p.processContainer(docker.ContainerEvent.Start, host, parsedContainerName, ip, url, port, opts) } -func (p *Processor) handleDockerEvent(event events.Message) { +func (p *Processor) handleDockerEvent(event events.Message, host string) { containerName, ok := event.Actor.Attributes["name"] if !ok { - log.Info(fmt.Sprintf("Skipping event for container with no name: %v", event.Actor.ID)) + log.Info(fmt.Sprintf("Skipping event for container with no name: %v", event.Actor.ID), "host", host) return } @@ -114,15 +120,15 @@ func (p *Processor) handleDockerEvent(event events.Message) { // This is not an error, it just means the container is not relevant for us return case *errors.MalformedIPLabelError, *errors.InvalidSchemeError: - log.Error("Failed to handle event for container", "container", containerName, "error", err) + log.Error("Failed to handle event for container", "host", host, "container", containerName, "error", err) } return } containerEvent, _ := docker.ContainerEvent.ParseString(string(event.Action)) - p.processContainer(containerEvent, containerName, ip, url, port, opts) + p.processContainer(containerEvent, host, containerName, ip, url, port, opts) } -func (p *Processor) handleAdguardHome(containerEvent docker.EventType, containerName, url, ip string, adguardHomeOptions adguardhome.AdguardHomeOptions) { +func (p *Processor) handleAdguardHome(host string, 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" @@ -131,57 +137,57 @@ func (p *Processor) handleAdguardHome(containerEvent docker.EventType, container switch containerEvent { case docker.ContainerEvent.Start: - log.Info("Adding a DNS rewrite to AdGuard Home", "container", containerName, "domain", url, "answer", ip) + log.Info("Adding a DNS rewrite to AdGuard Home", "host", host, "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) + log.Error("Failed to add a DNS rewrite to AdGuard Home", "host", host, "container", containerName, "domain", url, "answer", ip, "error", err) } case docker.ContainerEvent.Die: - log.Info("Deleting DNS rewrite from AdGuard Home", "container", containerName, "domain", url) + log.Info("Deleting DNS rewrite from AdGuard Home", "host", host, "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) + log.Error("Failed to delete DNS rewrite from AdGuard Home", "host", host, "container", containerName, "domain", url, "error", err) } } } } -func (p *Processor) handlePiHole(containerEvent docker.EventType, containerName, url, ip string, piholeOptions pihole.PiHoleOptions) { +func (p *Processor) handlePiHole(host string, containerEvent docker.EventType, containerName, url, ip string, piholeOptions pihole.PiHoleOptions) { if p.piholeClient != nil { switch containerEvent { case docker.ContainerEvent.Start: if piholeOptions.TargetDomain == "" { - log.Info("Adding a local DNS record to Pi-Hole", "container", containerName, "url", url, "ip", ip) + log.Info("Adding a local DNS record to Pi-Hole", "host", host, "container", containerName, "url", url, "ip", ip) err := p.piholeClient.AddDnsRecord(url, ip) if err != nil { - log.Error("Failed to add a local DNS record to Pi-Hole", "container", containerName, "url", url, "ip", ip, "error", err) + log.Error("Failed to add a local DNS record to Pi-Hole", "host", host, "container", containerName, "url", url, "ip", ip, "error", err) } } else { - log.Info("Adding a local CNAME record to Pi-Hole", "container", containerName, "url", url, "targetDomain", piholeOptions.TargetDomain) + log.Info("Adding a local CNAME record to Pi-Hole", "host", host, "container", containerName, "url", url, "targetDomain", piholeOptions.TargetDomain) err := p.piholeClient.AddCNameRecord(url, piholeOptions.TargetDomain) if err != nil { - log.Error("Failed to add a local CNAME record to Pi-Hole", "container", containerName, "url", url, "targetDomain", piholeOptions.TargetDomain, "error", err) + log.Error("Failed to add a local CNAME record to Pi-Hole", "host", host, "container", containerName, "url", url, "targetDomain", piholeOptions.TargetDomain, "error", err) } } case docker.ContainerEvent.Die: if piholeOptions.TargetDomain == "" { - log.Info("Deleting local DNS record from Pi-Hole", "container", containerName, "url", url) + log.Info("Deleting local DNS record from Pi-Hole", "host", host, "container", containerName, "url", url) err := p.piholeClient.DeleteDnsRecord(url) if err != nil { - log.Error("Failed to delete local DNS record from Pi-Hole", "container", containerName, "url", url, "error", err) + log.Error("Failed to delete local DNS record from Pi-Hole", "host", host, "container", containerName, "url", url, "error", err) } } else { - log.Info("Deleting local CNAME record from Pi-Hole", "container", containerName, "url", url, "targetDomain", piholeOptions.TargetDomain) + log.Info("Deleting local CNAME record from Pi-Hole", "host", host, "container", containerName, "url", url, "targetDomain", piholeOptions.TargetDomain) err := p.piholeClient.DeleteCNameRecord(url, piholeOptions.TargetDomain) if err != nil { - log.Error("Failed to delete local CNAME record from Pi-Hole", "container", containerName, "url", url, "targetDomain", piholeOptions.TargetDomain, "error", err) + log.Error("Failed to delete local CNAME record from Pi-Hole", "host", host, "container", containerName, "url", url, "targetDomain", piholeOptions.TargetDomain, "error", err) } } } } } -func (p *Processor) handleNpm(containerEvent docker.EventType, containerName, url, ip string, port int, npmProxyHostOptions npm.NpmProxyHostOptions) { +func (p *Processor) handleNpm(host string, containerEvent docker.EventType, containerName, url, ip string, port int, npmProxyHostOptions npm.NpmProxyHostOptions) { switch containerEvent { case docker.ContainerEvent.Start: npmProxyHost := npm.ProxyHost{ @@ -209,42 +215,42 @@ func (p *Processor) handleNpm(containerEvent docker.EventType, containerName, ur } } - log.Info("Adding entry to Nginx Proxy Manager", "container", containerName) + log.Info("Adding entry to Nginx Proxy Manager", "host", host, "container", containerName) err := p.npmClient.AddProxyHost(npmProxyHost) if err != nil { - log.Error("Failed to add entry to Nginx Proxy Manager", "container", containerName, "error", err) + log.Error("Failed to add entry to Nginx Proxy Manager", "host", host, "container", containerName, "error", err) } case docker.ContainerEvent.Die: - log.Info("Deleting entry from Nginx Proxy Manager", "container", containerName) + log.Info("Deleting entry from Nginx Proxy Manager", "host", host, "container", containerName) err := p.npmClient.DeleteProxyHost(url) if err != nil { - log.Error("Failed to delete entry from Nginx Proxy Manager", "container", containerName, "error", err) + log.Error("Failed to delete entry from Nginx Proxy Manager", "host", host, "container", containerName, "error", err) } } } -func (p *Processor) processContainer(containerEvent docker.EventType, containerName, ip, url string, port int, opts *docker.ClientOptions) { +func (p *Processor) processContainer(containerEvent docker.EventType, host, containerName, ip, url string, port int, opts *docker.ClientOptions) { msg := "Handling container" if p.dryRun { msg += ". In dry run mode, not doing anything." - log.Info(msg, "container", containerName, "ip", ip, "port", port, "url", url) + log.Info(msg, "host", host, "container", containerName, "ip", ip, "port", port, "url", url) return } - log.Info(msg, "container", containerName, "ip", ip, "port", port, "url", url) + log.Info(msg, "host", host, "container", containerName, "ip", ip, "port", port, "url", url) if p.npmClient != nil { npmHost := p.npmClient.GetIP() if opts.AdguardHome != nil { - p.handleAdguardHome(containerEvent, containerName, url, npmHost, *opts.AdguardHome) + p.handleAdguardHome(host, containerEvent, containerName, url, npmHost, *opts.AdguardHome) } if opts.Pihole != nil { - p.handlePiHole(containerEvent, containerName, url, npmHost, *opts.Pihole) + p.handlePiHole(host, containerEvent, containerName, url, npmHost, *opts.Pihole) } if opts.NPM != nil { - p.handleNpm(containerEvent, containerName, url, ip, port, *opts.NPM) + p.handleNpm(host, containerEvent, containerName, url, ip, port, *opts.NPM) } } }