├── .github └── workflows │ ├── dockerhub-push.yml │ └── dockerhub-readme.yml ├── Dockerfile ├── README.md ├── entryPoint.sh ├── healthCheck.sh ├── sshd_config ├── sync-dnsmasq.sh └── sync-pihole.sh /.github/workflows/dockerhub-push.yml: -------------------------------------------------------------------------------- 1 | name: Push to DockerHub 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths-ignore: [ README.md ] 7 | pull_request: 8 | branches: [ master ] 9 | paths-ignore: [ README.md ] 10 | 11 | workflow_dispatch: 12 | 13 | # https://github.com/docker/build-push-action/blob/master/docs/advanced/multi-platform.md 14 | jobs: 15 | build-push: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - 19 | name: Checkout 20 | uses: actions/checkout@v2 21 | - 22 | name: Set up QEMU 23 | uses: docker/setup-qemu-action@v1 24 | - 25 | name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v1 27 | - 28 | name: Login to DockerHub 29 | uses: docker/login-action@v1 30 | with: 31 | username: ${{ secrets.DOCKERHUB_USERNAME }} 32 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 33 | - 34 | name: Build and push 35 | uses: docker/build-push-action@v2 36 | with: 37 | context: . 38 | platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 39 | push: true 40 | tags: shirom/pihole-sync:latest 41 | -------------------------------------------------------------------------------- /.github/workflows/dockerhub-readme.yml: -------------------------------------------------------------------------------- 1 | name: Update Docker Hub Readme 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - README.md 8 | - .github/workflows/dockerhub-readme.yml 9 | 10 | workflow_dispatch: 11 | jobs: 12 | dockerHubDescription: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Docker Hub Description 18 | uses: peter-evans/dockerhub-description@v2 19 | with: 20 | username: ${{ secrets.DOCKERHUB_USERNAME }} 21 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 22 | repository: shirom/pihole-sync 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3 2 | 3 | RUN apk -U update 4 | RUN apk add --no-cache dumb-init openssh-client openssh-server rsync inotify-tools bind-tools bash 5 | 6 | ADD sync-dnsmasq.sh /sync-dnsmasq.sh 7 | ADD sync-pihole.sh /sync-pihole.sh 8 | ADD entryPoint.sh /entryPoint.sh 9 | ADD sshd_config / 10 | ADD healthCheck.sh /healthCheck.sh 11 | 12 | ENTRYPOINT ["dumb-init", "/entryPoint.sh"] 13 | 14 | HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD /healthCheck.sh 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Docker Pulls](https://img.shields.io/docker/pulls/shirom/pihole-sync.svg?style=for-the-badge&logo=github)](https://hub.docker.com/repository/docker/shirom/pihole-sync) 2 | 3 | # docker-pihole-sync 4 | A Docker Container To Sync Two Piholes. 5 | 6 | ## Introduction 7 | A Pihole runs your entire network. If it goes down, your whole network goes down. If you have a family at home, they're going to be pretty annoyed that the wifi goes out everytime you want to do some maintainence. The only solution to this problem is to have a redundant pihole on your network, but you don't want to change your settings in two different places. 8 | 9 | This repo allows you to synchronize between two piholes where one is the master and one is the slave. I'll be adding support for more piholes in future. Just update one pihole and the rest automatically update. It supports the `/etc/pihole/` and `/etc/dnsmasq.d/` directories, excluding some directories which should be client-independent. 10 | 11 | It is based on Alpine 3.12 and utilizes `dumb-init`, `openssh`, `rsync`, `inotify-tools`, and `bash` for an image size of about 28 MB. 12 | 13 | Because Pi-Hole Docker utilizes a UID/GID of 0:0 and 999:999, this presents a unique problem for sending files over SSH, as the only user who can receive the files and maintain the proper UID/GID flags is root. However, having a docker container SSH in to the root user of another host is undesirable for a number of security related reasons. 14 | 15 | By utilizing a `sender` container node on one Pi and a `receiver` container node on the other Pi, we're able to solve the issue of securely opening a root user to SSH, by having the `sender` container node SSH into the `receiver` container node, rather than the host. If the container were to be infiltrated, the infiltrater would have access to root only in the receiver container, and its mounted volumes. 16 | 17 | **NOTE**: The sending and recieving container are only necessary for solving permissions issues without giving root access to the recieving container. If you have no problem giving root access to the recieving end (at the cost of security), or your recieving Pihole is not running in Docker, you don't need to use the recieving container. 18 | 19 | ## Why Docker PiHole Sync 20 | 21 | There are other options out there such as [pihole-cloudsync](https://github.com/stevejenkins/pihole-cloudsync) and [pihole-sync](https://github.com/simonwhitaker/pihole-sync), but this repo offers 4 unique features: 22 | 23 | ### 1. Docker Support 24 | If you have a project based on docker, it doesn't make sense to have a single sync script running outside of docker. Your whole project should be started with docker-compose up and ended on docker-compose down. 25 | ### 2. Continuous Synchronization 26 | The code will monitor the selected the folder for changes and immediately update the other Pihole. Great for updating the whitelist and seeing the website work immediately. 27 | ### 3. All Settings Are Transferred 28 | Not only are your lists transferred, but all your other settings are transferred as well including your password, upstream DNS settings, etc. 29 | ### 4. Keeps Your Github Clean 30 | Unlike [pihole-cloudsync](https://github.com/stevejenkins/pihole-cloudsync), we don't require a repository to sync to. This means that your Piholes don't have to connect to the internet, and you don't have a large number of commits going into a dummy repository. This is especially nice if you show private contributions on your profile and don't want a huge number of changes being published to your Github profile 31 | 32 | **NOTE**: The 'sender' Pihole must be able to SSH into the 'receiver' Pihole. If that's a restriction (maybe your Piholes are behind different VPNs), use [pihole-cloudsync](https://github.com/stevejenkins/pihole-cloudsync). 33 | 34 | ## Setup 35 | ### docker-compose.yml 36 | 37 | This is the `docker-compose.yml` for the sender/master Pi-Hole: 38 | 39 | ```yaml 40 | pihole: 41 | image: pihole/pihole:latest 42 | volumes: 43 | - /mnt/ext/pihole/etc-pihole:/etc/pihole 44 | - /mnt/ext/pihole/etc-dnsmasq.d:/etc/dnsmasq.d 45 | rest of pihole config... 46 | 47 | pihole-sync-sender: 48 | image: shirom/pihole-sync:latest 49 | container_name: pihole-sync-sender 50 | volumes: 51 | - /mnt/ext/piholesync/root:/root 52 | - /mnt/ext/pihole/etc-pihole:/mnt/etc-pihole:ro 53 | - /mnt/ext/pihole/etc-dnsmasq.d:/mnt/etc-dnsmasq.d:ro 54 | environment: 55 | - "NODE=sender" 56 | - "REM_HOST=(IP address of remote Pi)" 57 | - "REM_SSH_PORT=22222" 58 | ``` 59 | 60 | This is the `docker-compose.yml` for the receiver/secondary Pi-Hole: 61 | 62 | ```yaml 63 | pihole: 64 | image: pihole/pihole:latest 65 | volumes: 66 | - /mnt/ext/pihole/etc-pihole:/etc/pihole 67 | - /mnt/ext/pihole/etc-dnsmasq.d:/etc/dnsmasq.d 68 | rest of pihole config... 69 | 70 | pihole-sync-receiver: 71 | image: shirom/pihole-sync:latest 72 | container_name: pihole-sync-receiver 73 | volumes: 74 | - /mnt/ext/piholesync/root:/root 75 | - /mnt/ext/piholesync/etc-ssh:/etc/ssh 76 | - /mnt/ext/pihole/etc-pihole:/mnt/etc-pihole 77 | - /mnt/ext/pihole/etc-dnsmasq.d:/mnt/etc-dnsmasq.d 78 | environment: 79 | - "NODE=receiver" 80 | ports: 81 | - 22222:22 82 | ``` 83 | 84 | ### Volumes 85 | Volume | Function 86 | --- | -------- 87 | `/mnt/ext/piholesync/root` | This is the directory in which the SSH key file and the known hosts file will be stored, so it needs to be persistent.
**Required on both nodes.** 88 | `/mnt/ext/piholesync/etc-ssh` | This is the directory in which the SSH server key files and the SSH daemon config will be stored, so it needs to be persistent. Can be a volume rather than a bind path, if you prefer.
**Required on the `sender` node only.** 89 | `/mnt/ext/pihole/etc-pihole` | This is the `/etc/pihole/` directory the Pi-Hole container writes to on the host filesystem. It is monitored and sychronized with the remote client directory. It should be set to the same as the /etc/pihole/ in the Pihole Docker container. See the compose file for details.
**Required on both nodes. Can be mounted read-only on the `sender` node.** 90 | `/mnt/ext/pihole/etc-dnsmasq.d` | This is the `/etc/dnsmasq.d/` directory the Pi-Hole container writes to on the host filesystem. It is monitored and sychronized with the remote client directory. It should be set to the same as the /etc/dnsmasq.d/ in the Pihole Docker container. See the compose file for details.
**Required on both nodes. Can be mounted read-only on the `sender` node.** 91 | 92 | ### Environment Variables 93 | Variable | Function 94 | --- | -------- 95 | `NODE` | This is where you should define if the container is the `sender` or the `receiver`.
**Required on both nodes.** 96 | `REM_HOST` | This is the IP address (or FQDN/Hostname) of the remote Pi that we're syncting to.
**Required on the `sender` node only.** 97 | `REM_SSH_PORT` | This is the non-standard SSH port that should be exposed on the container. Default of 22222 is probably fine. However, if you change this on the `sender` node, be sure to change the exposed port forward on the `receiver` node.
**Required on the `sender` node only.** 98 | 99 | ### Ports 100 | Port | Function 101 | --- | -------- 102 | `22222` | This is the port you want to expose for rsync/ssh. Your host is likely using 22 for SSH already, so it should be a non-standard port. The default of 22222 is probably fine. However, if you change this on the `receiver` node, be sure to change the `REM_SSH_PORT` on the `sender` node.
**Required on the `receiver` node only.** 103 | 104 | ## Support Information 105 | - Shell access while the container is running: `docker exec -it pihole-sync /bin/bash` 106 | - Logs: `docker logs pihole-sync` 107 | - Note the SSH-key instructions: 108 | 109 | On the receiver node, create an authorized_keys file in the config directory 110 | For example, if your 'config' volume mount on the receiver is: 111 | /docker/config/piholesync/root:/root" 112 | 113 | Then you would create a file at: 114 | /docker/config/piholesync/root/.ssh/authorized_keys" 115 | 116 | Copy/paste the contents between the ##### markers into that authorized_keys file:" 117 | 118 | ####### COPY BELOW THIS LINE, BUT NOT THIS LINE ########" 119 | /root/.ssh/id_ed25519.pub" 120 | ####### COPY ABOVE THIS LINE, BUT NOT THIS LINE ########" 121 | 122 | Once done, re-start the containers. 123 | 124 | 125 | ## Building Locally 126 | If you want to make local modifications to this image for development purposes or just to customize the logic: 127 | ``` 128 | docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 --tag shirom/pihole-sync --output type=image,push=false . 129 | ``` 130 | For multi-arch builds, make sure you have [QEMU](https://medium.com/@artur.klauser/building-multi-architecture-docker-images-with-buildx-27d80f7e2408) installed 131 | -------------------------------------------------------------------------------- /entryPoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | fail="1" 4 | if [[ -z "${NODE,,}" ]]; then 5 | fail="0" 6 | elif [[ "${NODE,,}" == "sender" ]]; then 7 | fail="0" 8 | elif [[ "${NODE,,}" == "receiver" ]]; then 9 | fail="0" 10 | elif ! grep -q "/mnt/etc-pihole" /etc/mtab; then 11 | echo "Please define a /mnt/etc-pihole config path." 12 | echo "This should be a physical path, such as:" 13 | echo "-v \"~/etc-pihole:/mnt/etc-pihole\"" 14 | fail="1" 15 | elif ! grep -q "/mnt/etc-dnsmasq.d" /etc/mtab; then 16 | echo "Please define a /mnt/etc-dnsmasq.d config path." 17 | echo "This should be a physical path, such as:" 18 | echo "-v \"~/etc-dnsmasq.d:/mnt/etc-dnsmasq.d\"" 19 | fail="1" 20 | fi 21 | if [[ "${fail}" -eq "1" ]]; then 22 | echo "Please set environmental flag 'NODE=[sender|receiver]'" 23 | exit 1 24 | fi 25 | 26 | if [[ "${NODE,,}" == "sender" ]]; then 27 | if ! grep -q "/root" /etc/mtab; then 28 | echo "Please define a root config path." 29 | echo "This should be a physical path, such as:" 30 | echo "-v \"~/piholesync/root:/root\"" 31 | exit 2 32 | fi 33 | if ! [[ -e "/root/.ssh/id_ed25519" ]]; then 34 | ssh-keygen -b 2048 -t ed25519 -f /root/.ssh/id_ed25519 -q -N "" 35 | chmod 400 "/root/.ssh/id_ed25519" 36 | echo "" 37 | echo "On the receiver node, create an authorized_keys file in the config directory" 38 | echo "" 39 | echo "For example, if your 'config' volume mount on the receiver is:" 40 | echo "-v /docker/config/piholesync/root:/root" 41 | echo "" 42 | echo "Then you would create a file at:" 43 | echo "/docker/config/piholesync/root/.ssh/authorized_keys" 44 | echo "" 45 | echo "Copy/paste the contents between the ##### markers into that authorized_keys file:" 46 | echo "" 47 | echo "####### COPY BELOW THIS LINE, BUT NOT THIS LINE ########" 48 | cat "/root/.ssh/id_ed25519.pub" 49 | echo "####### COPY ABOVE THIS LINE, BUT NOT THIS LINE ########" 50 | echo "" 51 | echo "Once done, re-start this conatiner." 52 | exit 0 53 | fi 54 | if ! [[ -e "/root/.ssh/known_hosts" ]]; then 55 | ssh-keyscan -p ${REM_SSH_PORT} ${REM_HOST} > /root/.ssh/known_hosts 2>/dev/null 56 | if [[ "${?}" -ne "0" || "$(wc -l "/root/.ssh/known_hosts" | awk '{print $1}')" -eq "0" ]]; then 57 | echo "Unable to initiate keyscan. Is the receiver online?" 58 | rm "/root/.ssh/known_hosts" 59 | exit 3 60 | fi 61 | fi 62 | rsync -a -P --exclude '01-pihole.conf' /mnt/etc-dnsmasq.d/ -e "ssh -p ${REM_SSH_PORT}" root@${REM_HOST}:/mnt/etc-dnsmasq.d/ --delete 63 | if [[ "${?}" -ne "0" ]]; then 64 | echo "Unable to initiate dnsmasq.d rsync. Is the receiver online?" 65 | exit 4 66 | fi 67 | rsync -a -P --exclude 'install.log' --exclude 'pihole-FTL.conf' --exclude 'versions' --exclude 'dhcp.leases' --exclude 'setupVars.conf' --exclude 'setupVars.conf.update.bak' --exclude 'pihole-FTL.db' -e "ssh -p ${REM_SSH_PORT}" /mnt/etc-pihole/ root@${REM_HOST}:/mnt/etc-pihole/ --delete 68 | if [[ "${?}" -ne "0" ]]; then 69 | echo "Unable to initiate dnsmasq.d rsync. Is the receiver online?" 70 | exit 5 71 | fi 72 | chmod +x /sync-dnsmasq.sh /sync-pihole.sh 73 | ( /sync-dnsmasq.sh ) & 74 | /sync-pihole.sh 75 | fi 76 | 77 | if [[ "${NODE,,}" == "receiver" ]]; then 78 | if ! grep -q "/etc/ssh" /etc/mtab; then 79 | echo "Please define an /etc/ssh config path." 80 | echo "This should be a physical path, such as:" 81 | echo "-v \"~/piholesync/etc-ssh:/etc/ssh\"" 82 | exit 6 83 | fi 84 | if ! grep -q "/root" /etc/mtab; then 85 | echo "Please define a root config path." 86 | echo "This should be a physical path, such as:" 87 | echo "-v \"~/piholesync/root:/root\"" 88 | exit 7 89 | fi 90 | if ! [[ -e "/root/.ssh/authorized_keys" ]]; then 91 | echo "Please obtain the 'authorized_keys' file from the sender," 92 | echo "and add it at your root/.ssh/authorized_keys path" 93 | exit 8 94 | fi 95 | sshKeyArr=("ssh_host_dsa_key" "ssh_host_dsa_key.pub" "ssh_host_ecdsa_key" "ssh_host_ecdsa_key.pub" "ssh_host_ed25519_key" "ssh_host_ed25519_key.pub" "ssh_host_rsa_key" "ssh_host_rsa_key.pub") 96 | for i in "${sshKeyArr[@]}"; do 97 | if ! [[ -e "/etc/ssh/${i}" ]]; then 98 | ssh-keygen -A 99 | fi 100 | done 101 | mv /sshd_config /etc/ssh/sshd_config 102 | # We can't SSH into the root user if it doesn't have a password set 103 | # Set a random 36 character string as the password 104 | rootPass="$(date +%s | sha256sum | base64 | head -c 36)" 105 | echo "root:${rootPass}" | chpasswd 106 | # Ensure permissions are correct on the root directory, or it won't let 107 | # us rsync/ssh in as the root user 108 | chmod 700 /root 109 | chmod 700 /root/.ssh 110 | chmod 600 /root/.ssh/authorized_keys 111 | /usr/sbin/sshd -D -e 112 | fi 113 | -------------------------------------------------------------------------------- /healthCheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "${NODE,,}" == "sender" ]]; then 4 | if [[ -e "/fail" ]]; then 5 | exit 1 6 | fi 7 | elif [[ "${NODE,,}" == "receiver" ]]; then 8 | netstat -plant | grep -q :22 9 | if [[ "${?}" -ne "0" ]]; then 10 | exit 2 11 | fi 12 | fi 13 | -------------------------------------------------------------------------------- /sshd_config: -------------------------------------------------------------------------------- 1 | Port 22 2 | AddressFamily any 3 | ListenAddress 0.0.0.0 4 | ListenAddress :: 5 | SyslogFacility AUTH 6 | LogLevel INFO 7 | LoginGraceTime 30s 8 | PermitRootLogin yes 9 | StrictModes yes 10 | MaxAuthTries 2 11 | MaxSessions 4 12 | PubkeyAuthentication yes 13 | AuthorizedKeysFile .ssh/authorized_keys 14 | PasswordAuthentication no 15 | PermitEmptyPasswords no 16 | ChallengeResponseAuthentication no 17 | AllowAgentForwarding yes 18 | AllowTcpForwarding yes 19 | GatewayPorts clientspecified 20 | X11Forwarding no 21 | PermitTTY yes 22 | PrintMotd no 23 | TCPKeepAlive yes 24 | PermitUserEnvironment no 25 | Compression delayed 26 | MaxStartups 10:20:30 27 | Subsystem sftp /usr/lib/openssh/sftp-server 28 | -------------------------------------------------------------------------------- /sync-dnsmasq.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | inotifywait -r -m -e close_write --exclude '01-pihole\.conf' --format '%w%f' /mnt/etc-dnsmasq.d/ | while read MODFILE 3 | do 4 | rsync -a -P --exclude '01-pihole.conf' /mnt/etc-dnsmasq.d/ -e "ssh -p ${REM_SSH_PORT}" root@${REM_HOST}:/mnt/etc-dnsmasq.d/ --delete 5 | if [[ "${?}" -ne "0" ]]; then 6 | touch /fail 7 | elif [[ -f "/fail" ]]; then 8 | rm -f /fail 9 | fi 10 | done 11 | -------------------------------------------------------------------------------- /sync-pihole.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | inotifywait -r -m -e close_write --exclude '((setupVars|setupVars|pihole-FTL)\.(conf|conf\.update\.bak|db)|local(branche|version)s|dhcp\.leases)' --format '%w%f' /mnt/etc-pihole/ | while read MODFILE 3 | do 4 | rsync -a -P --exclude 'install.log' --exclude 'pihole-FTL.conf' --exclude 'versions' --exclude 'dhcp.leases' --exclude 'setupVars.conf' --exclude 'setupVars.conf.update.bak' --exclude 'pihole-FTL.db' -e "ssh -p ${REM_SSH_PORT}" /mnt/etc-pihole/ root@${REM_HOST}:/mnt/etc-pihole/ --delete 5 | if [[ "${?}" -ne "0" ]]; then 6 | touch /fail 7 | elif [[ -f "/fail" ]]; then 8 | rm -f /fail 9 | fi 10 | done 11 | --------------------------------------------------------------------------------