commit 2bc3aa12f01d1533046e7658a90f57dd2ff99df2 Author: Adam Toy Date: Sun Jan 31 12:46:48 2021 -0500 first commit diff --git a/.github/workflows/docker-publish.yaml b/.github/workflows/docker-publish.yaml new file mode 100644 index 0000000..4c24f9d --- /dev/null +++ b/.github/workflows/docker-publish.yaml @@ -0,0 +1,35 @@ +name: Docker + +on: + push: + branches: + - master + +env: + IMAGE_NAME: atoy3731/adguard-sync + +jobs: + push: + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - uses: actions/checkout@v2 + + # Build: Build the Docker image with a temporary tag. + - name: Build image + run: docker build . --file Dockerfile --tag $IMAGE_NAME + + # Login: Log into Docker Hub using Github secrets. + - name: Log into Docker + env: + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + run: echo "$DOCKER_PASSWORD" | docker login -u $DOCKER_USER --password-stdin + + # Push: Retag the Docker image and push it to Docker Hub. + - name: Push image to DockerHub + run: | + VERSION=$(cat VERSION) + docker tag $IMAGE_NAME $IMAGE_NAME:$VERSION + docker push $IMAGE_NAME:$VERSION \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4296422 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +venv/ +*.pyc +.idea/ +*.iml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec4e572 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# AdGuard Sync + +This project will sync entries between two Primary and Secondary AdGuard Home instances using the API. + +This is useful if you're dependent on local DNS and want to ensure relative High Availability. + +### How to Run + +AdGuard Sync is packaged as a Docker image and can be ran anywhere with access to your instances. You can update the `docker-compose.yaml` file with your values based on the following: + +| Variable | Required | Description | Default | +|---|---|---|---| +| ADGUARD_PRIMARY | Yes | Primary base URL for the primary AdGuard instance (Ie. http://dns01.example.com) | N/A | +| ADGUARD_SECONDARY | Yes | Secondary base URL for the primary AdGuard instance (Ie. http://dns02.example.com) | N/A | +| ADGUARD_USER | Yes | Username to log into your AdGuard instances. | N/A | +| ADGUARD_PASS | Yes | Password to log into your AdGuard instances. | N/A | +| SECONDARY_ADGUARD_USER | No | Username to log into your secondary AdGuard instance. Only necessary if credentials are different between primary and secondary | Value of 'ADGUARD_USER' | +| SECONDARY_ADGUARD_PASS | No | Password to log into your secondary AdGuard instance. Only necessary if credentials are different between primary and secondary | Value of 'ADGUARD_PASSWORD' | +| REFRESH_INTERVAL_SECS | No | Frequency in seconds to refresh entries. | 60 | + +Once you've updated the file and ensure you have `docker` and `docker-compose` installed, run the following in the root directory: + +```bash +docker-compose up -d +``` + +You can check on the status of your newly running pod with: + +```bash +docker-compose logs +``` + +**NOTE:** The container is set to automatically restart when the docker daemon restarts. \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..9f8e9b6 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0 \ No newline at end of file diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..46f2b06 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,15 @@ +FROM alpine:3.13 + +RUN apk update && \ + apk add python3 curl && \ + curl --silent --show-error --retry 5 https://bootstrap.pypa.io/get-pip.py | python3 && \ + apk del curl + +COPY requirements.txt /tmp/requirements.txt + +RUN pip3 install -r /tmp/requirements.txt && \ + rm -f /tmp/requirements.txt + +COPY app.py /opt/app.py + +CMD [ '/usr/bin/python3', '/opt/app.py' ] diff --git a/build/app.py b/build/app.py new file mode 100644 index 0000000..0207c5a --- /dev/null +++ b/build/app.py @@ -0,0 +1,168 @@ +import requests +import os +import json +import time + +ADGUARD_PRIMARY = os.environ['ADGUARD_PRIMARY'] +ADGUARD_SECONDARY = os.environ['ADGUARD_SECONDARY'] + +ADGUARD_USER = os.environ['ADGUARD_USER'] +ADGUARD_PASS = os.environ['ADGUARD_PASS'] + +# Optional, use if your secondary AdGuard has different credentials +SECONDARY_ADGUARD_USER = os.environ.get('SECONDARY_ADGUARD_USER', ADGUARD_USER) +SECONDARY_ADGUARD_PASS = os.environ.get('SECONDARY_ADGUARD_PASS', ADGUARD_PASS) + +REFRESH_INTERVAL_SECS = int(os.environ.get('REFRESH_INTERVAL_SECS', '30')) + + +class UnauthenticatedError(Exception): + pass + + +def get_login_cookie(url, user, passwd): + """ + Logs into AdGuard URL using username/password and returns a valid session cookie. + :param url: Base URL of AdGuard + :param user: Username of AdGuard + :param passwd: Password of AdGuard + :return: Session token + """ + + creds = { + 'name': user, + 'password': passwd + } + + response = requests.post('{}/control/login'.format(url), data=json.dumps(creds)) + return response.cookies['agh_session'] + + +def get_entries(url, cookie): + """ + Retrieves all existing entries from AdGuard. + :param url: Base AdGuard URL + :param cookie: Session token + :return: Dict of Entries + """ + cookies = { + 'agh_session': cookie + } + response = requests.get('{}/control/rewrite/list'.format(url), cookies=cookies) + + if response.status_code == 403: + raise UnauthenticatedError + + entry_array = json.loads(response.text) + entry_dict = {} + + for e in entry_array: + entry_dict[e['domain']] = e['answer'] + + return entry_dict + + +def update_entries(url, cookie, sync_entries): + """ + Update entries from your primary to secondary AdGuard. + + ADD: Will add the entry with the domain pointing to IP. + UPDATE: Will update existing entry to point the domain to the new IP. + DEL: Will delete the existing entry from secondary AdGuard. + :param url: URL of the Secondary AdGuard + :param cookie: Secondary AdGuard Auth Cookie. + :param sync_entries: Array of entries to be sync. + :return: None + """ + + cookies = { + 'agh_session': cookie + } + + for entry in sync_entries: + if entry['action'] == 'ADD': + print(" - Adding entry ({} => {})".format(entry['domain'], entry['answer'])) + data = { + 'domain': entry['domain'], + 'answer': entry['answer'] + } + response = requests.post('{}/control/rewrite/add'.format(url), cookies=cookies, data=json.dumps(data)) + if response.status_code == 403: + raise UnauthenticatedError + + elif entry['action'] == 'DEL': + print(" - Deleting entry ({} => {})".format(entry['domain'], entry['answer'])) + data = { + 'domain': entry['domain'], + 'answer': entry['answer'] + } + response = requests.post('{}/control/rewrite/delete'.format(url), cookies=cookies, data=json.dumps(data)) + if response.status_code == 403: + raise UnauthenticatedError + + elif entry['action'] == 'UPDATE': + print(" - Updating entry ({} => {})".format(entry['domain'], entry['new_answer'])) + + old_data = { + 'domain': entry['domain'], + 'answer': entry['old_answer'] + } + + new_data = { + 'domain': entry['domain'], + 'answer': entry['new_answer'] + } + response = requests.post('{}/control/rewrite/delete'.format(url), cookies=cookies, data=json.dumps(old_data)) + if response.status_code == 403: + raise UnauthenticatedError + + response = requests.post('{}/control/rewrite/delete'.format(url), cookies=cookies, data=json.dumps(new_data)) + if response.status_code == 403: + raise UnauthenticatedError + + +if __name__ == '__main__': + print("Running Adguard Sync for '{}' => '{}'..".format(ADGUARD_PRIMARY, ADGUARD_SECONDARY)) + + # Get initial login cookie + primary_cookie = get_login_cookie(ADGUARD_PRIMARY, ADGUARD_USER, ADGUARD_PASS) + secondary_cookie = get_login_cookie(ADGUARD_SECONDARY, SECONDARY_ADGUARD_USER, SECONDARY_ADGUARD_PASS) + + while True: + try: + primary_entries = get_entries(ADGUARD_PRIMARY, primary_cookie) + secondary_entries = get_entries(ADGUARD_SECONDARY, secondary_cookie) + + sync_entries = [] + + for pk, pv in primary_entries.items(): + if pk not in secondary_entries: + sync_entries.append({ + 'action': 'ADD', + 'domain': pk, + 'answer': pv + }) + + elif secondary_entries[pk] != pv: + sync_entries.append({ + 'action': 'UPDATE', + 'domain': pk, + 'old_answer': secondary_entries[pk], + 'new_answer': pv + }) + + for sk, sv in secondary_entries.items(): + if sk not in primary_entries: + sync_entries.append({ + 'action': 'DEL', + 'domain': sk, + 'answer': sv + }) + + update_entries(ADGUARD_SECONDARY, secondary_cookie, sync_entries) + + except UnauthenticatedError: + primary_cookie = get_login_cookie(ADGUARD_PRIMARY, ADGUARD_USER, ADGUARD_PASS) + secondary_cookie = get_login_cookie(ADGUARD_SECONDARY, SECONDARY_ADGUARD_USER, SECONDARY_ADGUARD_PASS) + + time.sleep(REFRESH_INTERVAL_SECS) diff --git a/build/requirements.txt b/build/requirements.txt new file mode 100644 index 0000000..663bd1f --- /dev/null +++ b/build/requirements.txt @@ -0,0 +1 @@ +requests \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..49a29c7 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,19 @@ +version: "3" + +services: + adguard-sync: + image: atoy3731/adguard-sync:1.0 + container_name: adguard-sync + + restart: always + environment: + # Required variables + - ADGUARD_PRIMARY=http://dns01.example.com + - ADGUARD_SECONDARY=http://dns02.example.com + - ADGUARD_USER=admin + - ADGUARD_PASS=password + + # Optional variables + # - SECONDARY_ADGUARD_USER=other_admin + # - SECONDARY_ADGUARD_PASS=other_password + # - REFRESH_INTERVAL_SECS=30 \ No newline at end of file