Compare commits
39
Commits
v1.0
...
9fbbd34d8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fbbd34d8a | ||
|
|
5d2a797d46 | ||
|
|
afae89a9f8 | ||
|
|
b197fd5977 | ||
|
|
13926f2e9f | ||
|
|
ae16862a11 | ||
|
|
6ac20e25e3 | ||
|
|
620d00134c | ||
|
|
d13f79ed82 | ||
|
|
cf96acda92 | ||
|
|
d811faef45 | ||
|
|
85fb4b4e79 | ||
|
|
20ec627515 | ||
|
|
f8e6888d50 | ||
|
|
f9f8506963 | ||
|
|
a63be55cb7 | ||
|
|
0bd6a62eca | ||
|
|
b865da38f3 | ||
|
|
67b4984664 | ||
|
|
5eafd7c7cc | ||
|
|
a72b486b3b | ||
|
|
d051d9deb7 | ||
|
|
267d9e52e0 | ||
|
|
4753d80891 | ||
|
|
36611ef30b | ||
|
|
5272796d71 | ||
|
|
157a0f7830 | ||
|
|
13379a3419 | ||
|
|
fedbe1e227 | ||
|
|
15ba895b04 | ||
|
|
7676d34a39 | ||
|
|
c8451688ca | ||
|
|
bb6634ffc1 | ||
|
|
1e589ba91a | ||
|
|
2340aea618 | ||
|
|
5f54c87cf2 | ||
|
|
e3632a84fe | ||
|
|
125c4eaed7 | ||
|
|
1236970426 |
@@ -0,0 +1,22 @@
|
||||
# User/group id the process runs as, matches ownership of the /adguard mount
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
|
||||
# Timezone of the container
|
||||
TZ=Europe/Paris
|
||||
|
||||
# Country codes for CIDR lists, comma separated. Prefix with ! to exclude instead of include.
|
||||
# Full lists here: https://github.com/vulnebify/cidre/tree/main/output/cidr/ipv4
|
||||
BLOCK_COUNTRIES=cn,ru
|
||||
|
||||
# Scheduling: daily or weekly
|
||||
BLOCKLIST_CRON_TYPE=daily
|
||||
# If weekly, choose the day: mon, tue, wed, thu, fri, sat, sun
|
||||
BLOCKLIST_CRON_DAY=mon
|
||||
# Time of day to run the update, 24h HH:MM format
|
||||
BLOCKLIST_CRON_TIME=06:00
|
||||
|
||||
# Docker API URL used to restart the AdGuard container (via socket-proxy)
|
||||
DOCKER_API_URL=http://socket-proxy-adguard:2375
|
||||
# Name of the AdGuard Home container to restart
|
||||
ADGUARD_CONTAINER_NAME=adguardhome
|
||||
@@ -0,0 +1,281 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
jobs:
|
||||
build-and-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Scan for secrets
|
||||
run: |
|
||||
# docker cp, not a build COPY: a repo's own .dockerignore (e.g. one that
|
||||
# excludes .git for prod builds) would otherwise silently give an empty,
|
||||
# falsely-clean scan.
|
||||
CID=$(docker create zricethezav/gitleaks:v8.30.1 detect --source=/repo --no-banner -v)
|
||||
docker cp . "$CID:/repo"
|
||||
docker start -a "$CID"
|
||||
STATUS=$?
|
||||
docker rm "$CID" > /dev/null
|
||||
exit $STATUS
|
||||
|
||||
- name: Lint Dockerfile with hadolint
|
||||
run: docker run --rm -i hadolint/hadolint:v2.15.1-alpine hadolint --failure-threshold error - < Dockerfile
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -t adguard-cidre:ci . 2>&1 | tee build.log
|
||||
if grep -q "Building wheel for" build.log; then
|
||||
echo "::warning::A dependency was built from source — check Python/Alpine compatibility"
|
||||
fi
|
||||
|
||||
- name: Smoke test (syntax check)
|
||||
run: |
|
||||
docker run --rm --entrypoint python adguard-cidre:ci -c "
|
||||
import ast
|
||||
with open('blocklist_scheduler.py') as f:
|
||||
source = f.read()
|
||||
try:
|
||||
ast.parse(source)
|
||||
print('OK: syntax is valid')
|
||||
except SyntaxError as e:
|
||||
print(f'::error::Syntax error: {e}')
|
||||
exit(1)
|
||||
"
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
docker build --target test -t adguard-cidre:test .
|
||||
docker run --rm adguard-cidre:test pytest -v --cov=. --cov-report=term-missing --cov-fail-under=75
|
||||
|
||||
- name: Lint with ruff
|
||||
run: docker build --target lint -t adguard-cidre:lint .
|
||||
|
||||
- name: Check deprecation warnings
|
||||
run: |
|
||||
docker run --rm --entrypoint python adguard-cidre:ci -W error::DeprecationWarning -c "import blocklist_scheduler" 2>&1 | tee deprecation.log || true
|
||||
if grep -qi "deprecat" deprecation.log; then
|
||||
echo "::warning::Deprecation warning detected, check logs"
|
||||
fi
|
||||
|
||||
- name: Scan with Trivy (critical - blocking)
|
||||
id: trivy_critical
|
||||
continue-on-error: true
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e DOCKER_HOST=tcp://dockerhost:2375 \
|
||||
--add-host=dockerhost:host-gateway \
|
||||
aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL adguard-cidre:ci
|
||||
|
||||
- name: Handle CRITICAL findings
|
||||
if: steps.trivy_critical.outcome == 'failure'
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" != "schedule" ]; then
|
||||
echo "::error::CRITICAL vulnerabilities found, failing the build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Scheduled scan found CRITICAL vulnerabilities — attempting an automatic apk upgrade + rescan."
|
||||
|
||||
sed -i '/^FROM .* AS base$/a RUN apk upgrade --no-cache' Dockerfile
|
||||
docker build -t adguard-cidre:remediated .
|
||||
|
||||
if docker run --rm \
|
||||
-e DOCKER_HOST=tcp://dockerhost:2375 \
|
||||
--add-host=dockerhost:host-gateway \
|
||||
aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL adguard-cidre:remediated; then
|
||||
echo "apk upgrade clears the CRITICAL finding(s) — opening a PR for review."
|
||||
|
||||
BRANCH="auto/cve-fix-$(date +%Y%m%d)-$(echo "${{ github.sha }}" | cut -c1-7)"
|
||||
git config user.name "adguard-cidre-ci"
|
||||
git config user.email "[email protected]"
|
||||
git checkout -b "$BRANCH"
|
||||
git add Dockerfile
|
||||
git commit -m "Auto-remediate CRITICAL CVE via apk upgrade"
|
||||
git config --unset-all http.https://git.djeex.fr/.extraheader || true
|
||||
git push "https://Djeex:${{ secrets.CI_PUSH_TOKEN }}@git.djeex.fr/Djeex/adguard-cidre.git" "HEAD:$BRANCH"
|
||||
|
||||
PR_JSON=$(curl -s -X POST \
|
||||
-H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg head "$BRANCH" '{title: "🔒 Auto: remediate CRITICAL CVE via apk upgrade", head: $head, base: "main", body: "Opened automatically by the scheduled CVE scan. An `apk upgrade --no-cache` cleared the CRITICAL Trivy finding(s) in a rebuild — review the diff and merge to publish the fix."}')" \
|
||||
"https://git.djeex.fr/api/v1/repos/Djeex/adguard-cidre/pulls")
|
||||
echo "PR API response: $(echo "$PR_JSON" | jq -r '.html_url // .message // "unknown"')"
|
||||
else
|
||||
echo "::error::apk upgrade does not clear the CRITICAL finding(s) — no automatic fix available, needs manual review."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Scan with Trivy (high - informative)
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e DOCKER_HOST=tcp://dockerhost:2375 \
|
||||
--add-host=dockerhost:host-gateway \
|
||||
aquasec/trivy:0.74.0 image --exit-code 0 --severity HIGH adguard-cidre:ci
|
||||
|
||||
- name: Publish tagged image
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
BEFORE="${{ github.event.before }}"
|
||||
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
|
||||
BASE_REF="$BEFORE"
|
||||
else
|
||||
BASE_REF="HEAD~1"
|
||||
fi
|
||||
CHANGED=$(git diff --name-only "$BASE_REF" "${{ github.sha }}")
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
|
||||
if ! echo "$CHANGED" | grep -qE '^(Dockerfile|blocklist_scheduler\.py|VERSION)$'; then
|
||||
echo "No container-relevant file changed, skipping publish."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if echo "$CHANGED" | grep -qE '^VERSION$'; then
|
||||
echo "VERSION was manually edited in this push, using it as-is."
|
||||
else
|
||||
echo "VERSION untouched but container files changed, auto-bumping the build number (Z)."
|
||||
OLD_VERSION=$(tr -d '[:space:]' < VERSION)
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$OLD_VERSION"
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
echo "$NEW_VERSION" > VERSION
|
||||
|
||||
git config user.name "adguard-cidre-ci"
|
||||
git config user.email "[email protected]"
|
||||
git add VERSION
|
||||
git commit -m "Bump build version to $NEW_VERSION [skip ci]"
|
||||
|
||||
# Belt and suspenders: actions/checkout can leave its own ephemeral
|
||||
# credential injected as an extraheader, which would silently override
|
||||
# the URL-embedded token below. persist-credentials:false on checkout
|
||||
# should already prevent this, but strip it here too just in case.
|
||||
git config --unset-all http.https://git.djeex.fr/.extraheader || true
|
||||
|
||||
git push "https://Djeex:${{ secrets.CI_PUSH_TOKEN }}@git.djeex.fr/Djeex/adguard-cidre.git" HEAD:main
|
||||
fi
|
||||
|
||||
VERSION=$(tr -d '[:space:]' < VERSION)
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||
MINOR_TAG="${MAJOR}.${MINOR}"
|
||||
|
||||
IMAGE=git.djeex.fr/djeex/adguard-cidre
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.djeex.fr -u Djeex --password-stdin
|
||||
|
||||
# Retag the already-built, already-scanned image — never rebuild for publish,
|
||||
# so what ships is byte-for-byte what Trivy just scanned.
|
||||
docker tag adguard-cidre:ci "$IMAGE:latest"
|
||||
docker tag adguard-cidre:ci "$IMAGE:$MINOR_TAG"
|
||||
docker tag adguard-cidre:ci "$IMAGE:$VERSION"
|
||||
docker push "$IMAGE:latest"
|
||||
docker push "$IMAGE:$MINOR_TAG"
|
||||
docker push "$IMAGE:$VERSION"
|
||||
|
||||
GHCR_IMAGE=ghcr.io/djeex/adguard-cidre
|
||||
echo "${{ secrets.GH_TOKEN }}" | docker login ghcr.io -u Djeex --password-stdin
|
||||
|
||||
docker tag adguard-cidre:ci "$GHCR_IMAGE:latest"
|
||||
docker tag adguard-cidre:ci "$GHCR_IMAGE:$MINOR_TAG"
|
||||
docker tag adguard-cidre:ci "$GHCR_IMAGE:$VERSION"
|
||||
docker push "$GHCR_IMAGE:latest"
|
||||
docker push "$GHCR_IMAGE:$MINOR_TAG"
|
||||
docker push "$GHCR_IMAGE:$VERSION"
|
||||
|
||||
TRIGGER_MSG=$(git log -1 --format=%s "${{ github.sha }}")
|
||||
PR_NUM=$(echo "$TRIGGER_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true)
|
||||
|
||||
CATEGORY="🔧 Maintenance"
|
||||
CHANGE_TITLE="$TRIGGER_MSG"
|
||||
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
PR_JSON=$(curl -s -H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
|
||||
"https://git.djeex.fr/api/v1/repos/Djeex/adguard-cidre/pulls/$PR_NUM")
|
||||
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title // empty' 2>/dev/null || true)
|
||||
LABELS=$(echo "$PR_JSON" | jq -r '.labels[]?.name' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$PR_TITLE" ]; then
|
||||
CHANGE_TITLE="$PR_TITLE"
|
||||
fi
|
||||
|
||||
if echo "$LABELS" | grep -qx 'bug'; then
|
||||
CATEGORY="⚠️ Hotfix"
|
||||
elif echo "$LABELS" | grep -qx 'major'; then
|
||||
CATEGORY="💥 Breaking change"
|
||||
elif echo "$LABELS" | grep -qx 'minor'; then
|
||||
CATEGORY="✨ Update"
|
||||
fi
|
||||
fi
|
||||
|
||||
REPO_URL="https://git.djeex.fr/Djeex/adguard-cidre"
|
||||
COMMIT_LIST=$(git log --no-merges --format="- %s ([%h](${REPO_URL}/commit/%H))" "$BASE_REF".."${{ github.sha }}")
|
||||
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
SOURCE_LINE="[${SHORT_SHA}](${REPO_URL}/commit/${{ github.sha }})"
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
SOURCE_LINE="[#${PR_NUM}](${REPO_URL}/pulls/${PR_NUM}) · ${SOURCE_LINE}"
|
||||
fi
|
||||
|
||||
BODY=$(cat <<EOF
|
||||
## Changelog
|
||||
|
||||
### ${CATEGORY}
|
||||
${CHANGE_TITLE}
|
||||
|
||||
**Source:** ${SOURCE_LINE}
|
||||
**Image:** \`${IMAGE}:${VERSION}\`
|
||||
|
||||
**Commits:**
|
||||
${COMMIT_LIST}
|
||||
EOF
|
||||
)
|
||||
|
||||
JSON_PAYLOAD=$(jq -n \
|
||||
--arg tag "$VERSION" \
|
||||
--arg name "$VERSION" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, name: $name, target_commitish: "main", body: $body}')
|
||||
|
||||
curl -s -o /dev/null -w "Release API response: %{http_code}\n" -X POST \
|
||||
-H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_PAYLOAD" \
|
||||
"https://git.djeex.fr/api/v1/repos/Djeex/adguard-cidre/releases"
|
||||
|
||||
MIRROR_NOTICE="_This github repo is a mirror of https://git.djeex.fr/Djeex/adguard-cidre. You'll find full package, PR, history and release note there._"
|
||||
GH_BODY=$(printf '%s\n\n%s' "$MIRROR_NOTICE" "$BODY")
|
||||
|
||||
GH_JSON_PAYLOAD=$(jq -n \
|
||||
--arg tag "$VERSION" \
|
||||
--arg name "$VERSION" \
|
||||
--arg body "$GH_BODY" \
|
||||
--arg sha "${{ github.sha }}" \
|
||||
'{tag_name: $tag, name: $name, target_commitish: $sha, body: $body}')
|
||||
|
||||
GH_STATUS=0
|
||||
for i in 1 2 3 4 5; do
|
||||
GH_STATUS=$(curl -s -o /tmp/gh_release.json -w "%{http_code}" -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.GH_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$GH_JSON_PAYLOAD" \
|
||||
"https://api.github.com/repos/Djeex/adguard-cidre/releases")
|
||||
if [ "$GH_STATUS" = "201" ]; then
|
||||
break
|
||||
fi
|
||||
echo "GitHub Release attempt $i failed (HTTP $GH_STATUS) — mirror may not have synced this commit yet, retrying in 15s..."
|
||||
sleep 15
|
||||
done
|
||||
echo "GitHub Release API response: $GH_STATUS"
|
||||
if [ "$GH_STATUS" != "201" ]; then
|
||||
cat /tmp/gh_release.json 2>/dev/null || true
|
||||
fi
|
||||
@@ -1,2 +1,4 @@
|
||||
/adguard/*.log
|
||||
/tmp/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
|
||||
+21
-22
@@ -1,30 +1,29 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.14.7-alpine AS base
|
||||
|
||||
# Install required utilities
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
cron \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ENV TZ=Europe/Paris
|
||||
|
||||
# Install python dependencies
|
||||
RUN pip install --no-cache-dir requests
|
||||
RUN apk add --no-cache tzdata curl su-exec \
|
||||
&& cp /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone
|
||||
|
||||
# Create crontabs directory (if needed)
|
||||
RUN mkdir -p /etc/crontabs
|
||||
WORKDIR /app
|
||||
|
||||
# Copy scripts
|
||||
COPY update-blocklist.py /usr/local/bin/update-blocklist.py
|
||||
COPY entrypoint.py /usr/local/bin/entrypoint.py
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Make scripts executable
|
||||
RUN chmod +x /usr/local/bin/update-blocklist.py /usr/local/bin/entrypoint.py
|
||||
COPY blocklist_scheduler.py entrypoint.sh VERSION ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Set default timezone (can be overridden with TZ env var)
|
||||
ENV TZ=UTC
|
||||
FROM base AS test
|
||||
RUN pip install --no-cache-dir pytest==9.1.1 pytest-cov==7.1.0
|
||||
COPY tests/ tests/
|
||||
COPY pytest.ini .
|
||||
|
||||
# Configure timezone (tzdata)
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
FROM base AS lint
|
||||
RUN pip install --no-cache-dir ruff==0.16.4
|
||||
COPY ruff.toml .
|
||||
COPY tests/ tests/
|
||||
RUN ruff check . && ruff format --check .
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.py"]
|
||||
FROM base
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
@@ -1,52 +1,60 @@
|
||||
<h1 align="center"> Adguard CIDRE Sync</h1>
|
||||
<div align="center">
|
||||
<a href="https://discord.gg/gxffg3GA96">
|
||||
<img src="https://img.shields.io/badge/JV%20hardware-rejoindre-green?style=flat-square&logo=discord&logoColor=%23fff" alt="JV Hardware">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
**Adguard CIDRE Sync** - A bot to synchronize adguard clients disallow list with countries CIDR list of your choices.
|
||||
|
||||
*The code is partially generated by AI*
|
||||
> [!NOTE]
|
||||
>_The code was partially written and structured using a generative AI._
|
||||
>
|
||||
>_Github repo is a mirror of https://git.djeex.fr/Djeex/adguard-cidre. You'll find full package, history and release note there._
|
||||
|
||||
## Sommaire
|
||||
|
||||
- [Features](#features)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Volumes](#volumes)
|
||||
- [File Structure](#file-structure)
|
||||
- [Installation and Usage](#nstallation-and-usage)
|
||||
- [Installation and Usage](#installation-and-usage)
|
||||
|
||||
## Features
|
||||
|
||||
- Automatically downloads IP CIDR blocks for specified countries to block.
|
||||
- Supports additional manually blocked IPs from a configurable file.
|
||||
- Updates the disallowed_clients section in the AdGuard Home config.
|
||||
- Configurable update frequency via cron expression environment variable.
|
||||
- Automatically restarts the AdGuard Home container after updates via Docker socket proxy.
|
||||
- Backup `AdguardHome.yaml` at first startup, then create a second backup at each update.
|
||||
- Downloads CIDR lists by country from GitHub
|
||||
- (Optional) Adds manual IPs from a `manually_blocked_ips.conf` file
|
||||
- Updates the `AdGuardHome.yaml` file by replacing the `disallowed_clients` list
|
||||
- Creates a backup of the original config (`AdGuardHome.yaml.first-start.bak`) on first run
|
||||
- Creates a backup before each update (`AdGuardHome.yaml.last-update.bak`)
|
||||
- Restarts the AdGuard Home container via Docker API
|
||||
- Built-in Python scheduler using the `schedule` library, configurable to run updates daily or weekly
|
||||
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------- | ---------------------------------------------------------- | --------------------------------- |
|
||||
| `TZ` | Your Time Zone | (required) |
|
||||
| `BLOCK_COUNTRIES` | Comma-separated country codes to block (e.g., `CN,RU,IR`) | (required) |
|
||||
| `BLOCKLIST_CRON` | Cron expression for update frequency (e.g., `0 6 * * *`) | `0 6 * * *` (at 6:00 everydays) |
|
||||
| `DOCKER_API_URL` | URL of Docker socket proxy to restart AdGuard container | `http://socket-proxy-adguard:2375` |
|
||||
| `ADGUARD_CONTAINER_NAME` | Name of your adguard container | `adguardhome` |
|
||||
|
||||
| Variable | Description | Example | Possible Values |
|
||||
|--------------------------|--------------------------------------------------------------------------|-----------------------------|---------------------------------------------|
|
||||
| `PUID` | User ID the process runs as (drops root at startup) | `1000` | Any valid numeric UID |
|
||||
| `PGID` | Group ID the process runs as | `1000` | Any valid numeric GID |
|
||||
| `TZ` | Timezone of the container to correctly schedule updates | `Europe/Paris` | Any valid timezone (e.g., `UTC`, `America/New_York`, etc.) |
|
||||
| `BLOCK_COUNTRIES` | List of country codes for CIDR lists, separated by commas. You can also define an exclude list (all countries except the specified ones) by prefixing each country code with !. Mixing inclusion and exclusion codes is not supported. | including list : `cn,ru,ir`, excluding list : `!cn,!ru,!ir` | ISO 2-letter country codes |
|
||||
| `BLOCKLIST_CRON_TYPE` | Scheduling type: `daily` or `weekly` | `daily` | `daily`, `weekly` |
|
||||
| `BLOCKLIST_CRON_TIME` | Time to run update in `HH:MM` 24-hour format | `06:00` | 24-hour time format |
|
||||
| `BLOCKLIST_CRON_DAY` | Day of the week for weekly schedule (e.g., `mon`, `tue`, etc.) | `mon` | `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun` |
|
||||
| `ADGUARD_CONTAINER_NAME` | Name of the AdGuard Home container to restart | `adguardhome` | Valid Docker container name |
|
||||
| `DOCKER_API_URL` | Docker API URL (used to restart the container) | `http://socket-proxy-adguard:2375` | HTTP URL |
|
||||
|
||||
## Volumes
|
||||
|
||||
- `/path/to/adguard/confdir` : configuration directory containing `AdGuardHome.yaml` from your adguard container, and optionally `manually_blocked_ips.conf`.
|
||||
|
||||
## File Structure
|
||||
|
||||
- `update-blocklist.sh`: Main script to download CIDRs, merge manual IPs, update config, and restart AdGuard.
|
||||
- `entrypoint.sh`: Sets up the cron job to periodically run the update script.
|
||||
- `Dockerfile`: Builds the lightweight Alpine-based image.
|
||||
- `blocklist_scheduler.py`: Script to backup, schedule, download CIDRs, merge manual IPs, update config, and restart AdGuard.
|
||||
- `Dockerfile`: Builds the lightweight python3-slim image.
|
||||
- `docker-compose.yml`: Example compose file to run the container.
|
||||
- `manually_blocked_ips.conf`: (Volume mount) Add extra IPs to block manually.
|
||||
- (optional) `manually_blocked_ips.conf`: Add extra IPs to block manually.
|
||||
|
||||
## Installation and Usage
|
||||
|
||||
### With our docker image
|
||||
### With our provided docker image
|
||||
|
||||
1. **Create `docker-compose.yml` in your `adguard-cidre` folder**
|
||||
|
||||
@@ -58,9 +66,14 @@
|
||||
container_name: adguard-cidre
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PUID=1000 # user id the process runs as, matches ownership of the /adguard mount
|
||||
- PGID=1000 # group id the process runs as
|
||||
- TZ=Europe/Paris # change to your timezone
|
||||
- BLOCK_COUNTRIES=cn,ru # choose countries listed IP to block. Full lists here https://github.com/vulnebify/cidre/tree/main/output/cidr/ipv4
|
||||
- BLOCKLIST_CRON=0 6 * * * # at 6:00 every days
|
||||
- BLOCKLIST_CRON_TYPE=daily # daily or weekly
|
||||
# if weekly, choose the day
|
||||
# - BLOCKLIST_CRON_DAY=mon
|
||||
- BLOCKLIST_CRON_TIME=06:00
|
||||
- DOCKER_API_URL=http://socket-proxy-adguard:2375 # docker socket proxy
|
||||
- ADGUARD_CONTAINER_NAME=adguardhome # adguard container name
|
||||
volumes:
|
||||
@@ -84,7 +97,7 @@
|
||||
2. **Modify docker-compose.yml**
|
||||
|
||||
- Set `BLOCK_COUNTRIES` environment variable with the countries you want to block.
|
||||
- Adjust `BLOCKLIST_CRON` if you want a different update frequency.
|
||||
- Adjust `BLOCKLIST_CRON` variables if you want a different update frequency.
|
||||
- Bind mount your adguard configuration folder (wich contains `AdGuardHome.yaml`) to `/adguard`
|
||||
- (optionnally) create and edit `manually_blocked_ips.conf` file in your adguard configuration folder to add other IPs you want to block. Only valid IP or CIDR entries will be processed, for exemple :
|
||||
|
||||
@@ -112,11 +125,12 @@
|
||||
git clone https://git.djeex.fr/Djeex/adguard-cidre
|
||||
cd adguard-cidre
|
||||
```
|
||||
2. **Modify docker-compose.yml**
|
||||
2. **Edit the `.env` file**
|
||||
|
||||
- Set `BLOCK_COUNTRIES` environment variable with the countries you want to block.
|
||||
- Adjust `BLOCKLIST_CRON` if you want a different update frequency.
|
||||
- Bind mount your adguard configuration folder (wich contains `AdGuardHome.yaml`) to `/adguard`
|
||||
- A `.env` file is included at the repo root with all environment variables (see [Environment Variables](#environment-variables)). Edit values there instead of `docker-compose.yml`.
|
||||
- Set `BLOCK_COUNTRIES` with the countries you want to block.
|
||||
- Adjust `BLOCKLIST_CRON_*` variables if you want a different update frequency.
|
||||
- Bind mount your adguard configuration folder (wich contains `AdGuardHome.yaml`) to `/adguard` in `docker-compose.yml`.
|
||||
- (optionnally) create and edit `manually_blocked_ips.conf` file in your adguard configuration folder to add other IPs you want to block. Only valid IP or CIDR entries will be processed, for exemple :
|
||||
|
||||
```bash
|
||||
@@ -128,7 +142,6 @@
|
||||
3. **Build and start the container**
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
4. **Check logs to verify updates**
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import schedule
|
||||
import yaml
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
|
||||
ADGUARD_YAML = Path("/adguard/AdGuardHome.yaml")
|
||||
TMP_YAML = ADGUARD_YAML.parent / (ADGUARD_YAML.name + ".tmp")
|
||||
MANUAL_IPS_FILE = Path("/adguard/manually_blocked_ips.conf")
|
||||
CIDR_BASE_URL = "https://raw.githubusercontent.com/vulnebify/cidre/main/output/cidr/ipv4"
|
||||
COUNTRY_LIST_URL = (
|
||||
"https://raw.githubusercontent.com/vulnebify/cidre/refs/heads/main/cidre/countries.py"
|
||||
)
|
||||
|
||||
FIRST_BACKUP = ADGUARD_YAML.parent / "AdGuardHome.yaml.first-start.bak"
|
||||
LAST_UPDATE_BACKUP = ADGUARD_YAML.parent / "AdGuardHome.yaml.last-update.bak"
|
||||
|
||||
BLOCK_COUNTRIES = os.getenv("BLOCK_COUNTRIES", "")
|
||||
BLOCKLIST_CRON_TYPE = os.getenv("BLOCKLIST_CRON_TYPE", "daily").lower()
|
||||
BLOCKLIST_CRON_TIME = os.getenv("BLOCKLIST_CRON_TIME", "06:00")
|
||||
BLOCKLIST_CRON_DAY = os.getenv("BLOCKLIST_CRON_DAY", "mon").lower()
|
||||
|
||||
ADGUARD_CONTAINER_NAME = os.getenv("ADGUARD_CONTAINER_NAME", "adguardhome")
|
||||
DOCKER_API_URL = os.getenv("DOCKER_API_URL", "http://socket-proxy-adguard:2375")
|
||||
|
||||
|
||||
def backup_first_start():
|
||||
if not FIRST_BACKUP.exists():
|
||||
logging.info(f"Creating first start backup: {FIRST_BACKUP}")
|
||||
FIRST_BACKUP.write_text(ADGUARD_YAML.read_text())
|
||||
else:
|
||||
logging.info("First start backup already exists, skipping.")
|
||||
|
||||
|
||||
def backup_last_update():
|
||||
logging.info(f"Creating last update backup: {LAST_UPDATE_BACKUP}")
|
||||
LAST_UPDATE_BACKUP.write_text(ADGUARD_YAML.read_text())
|
||||
|
||||
|
||||
def fetch_all_country_codes():
|
||||
try:
|
||||
resp = requests.get(COUNTRY_LIST_URL, timeout=15)
|
||||
resp.raise_for_status()
|
||||
matches = re.findall(r'"([A-Z]{2})"', resp.text)
|
||||
return set(code.lower() for code in matches)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to fetch available country codes: {e}")
|
||||
return set()
|
||||
|
||||
|
||||
def get_selected_countries():
|
||||
if not BLOCK_COUNTRIES:
|
||||
logging.error("BLOCK_COUNTRIES is not set. Skipping update.")
|
||||
return []
|
||||
|
||||
raw_codes = [c.strip() for c in BLOCK_COUNTRIES.split(",") if c.strip()]
|
||||
if not raw_codes:
|
||||
logging.error("No valid country codes provided.")
|
||||
return []
|
||||
|
||||
is_exclusion = all(c.startswith("!") for c in raw_codes)
|
||||
is_inclusion = all(not c.startswith("!") for c in raw_codes)
|
||||
|
||||
if not (is_exclusion or is_inclusion):
|
||||
logging.error(
|
||||
"Mixed syntax in BLOCK_COUNTRIES. Use only inclusion (e.g. 'fr,de') or only exclusion (e.g. '!fr,!de')."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
available = fetch_all_country_codes()
|
||||
selected = {c.lstrip("!") for c in raw_codes}
|
||||
unknown = selected - available
|
||||
if unknown:
|
||||
logging.warning(f"Unknown country codes: {', '.join(sorted(unknown))}")
|
||||
|
||||
if is_exclusion:
|
||||
return sorted(available - selected)
|
||||
else:
|
||||
return sorted(selected & available)
|
||||
|
||||
|
||||
def download_cidr_lists(countries):
|
||||
combined_ips = []
|
||||
for code in countries:
|
||||
url = f"{CIDR_BASE_URL}/{code.lower()}.cidr"
|
||||
logging.info(f"Downloading CIDR list for {code} from {url}")
|
||||
try:
|
||||
r = requests.get(url, timeout=30)
|
||||
r.raise_for_status()
|
||||
ips = r.text.strip().splitlines()
|
||||
logging.info(f"Downloaded {len(ips)} CIDR entries for {code}")
|
||||
combined_ips.extend(ips)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to download {code}: {e}")
|
||||
return combined_ips
|
||||
|
||||
|
||||
def read_manual_ips():
|
||||
if MANUAL_IPS_FILE.exists():
|
||||
logging.info(f"Reading manual IPs from {MANUAL_IPS_FILE}")
|
||||
valid_ips = []
|
||||
with MANUAL_IPS_FILE.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and (line.count(".") == 3 or "/" in line):
|
||||
valid_ips.append(line)
|
||||
logging.info(f"Added {len(valid_ips)} manual IP entries")
|
||||
return valid_ips
|
||||
else:
|
||||
logging.info("Manual IPs file does not exist, skipping.")
|
||||
return []
|
||||
|
||||
|
||||
def update_yaml_with_ips(ips):
|
||||
if not ADGUARD_YAML.exists():
|
||||
logging.error(f"{ADGUARD_YAML} does not exist. Cannot update.")
|
||||
return False
|
||||
|
||||
try:
|
||||
with ADGUARD_YAML.open() as f:
|
||||
data = yaml.safe_load(f)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to parse YAML file: {e}")
|
||||
return False
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logging.error("Invalid YAML format.")
|
||||
return False
|
||||
|
||||
data["dns"]["disallowed_clients"] = ips
|
||||
|
||||
with TMP_YAML.open("w") as f:
|
||||
yaml.safe_dump(data, f)
|
||||
|
||||
TMP_YAML.replace(ADGUARD_YAML)
|
||||
logging.info(f"Updated {ADGUARD_YAML} with new disallowed clients list.")
|
||||
return True
|
||||
|
||||
|
||||
def restart_adguard_container():
|
||||
restart_url = f"{DOCKER_API_URL}/containers/{ADGUARD_CONTAINER_NAME}/restart"
|
||||
logging.info(f"Restarting AdGuard container '{ADGUARD_CONTAINER_NAME}'...")
|
||||
try:
|
||||
resp = requests.post(restart_url, timeout=10)
|
||||
if resp.status_code == 204:
|
||||
logging.info("AdGuard container restarted successfully.")
|
||||
else:
|
||||
logging.error(f"Failed to restart container: {resp.status_code} {resp.text}")
|
||||
except Exception as e:
|
||||
logging.error(f"Error restarting container: {e}")
|
||||
|
||||
|
||||
def update_blocklist():
|
||||
countries = get_selected_countries()
|
||||
if not countries:
|
||||
logging.error("No valid countries to process. Skipping update.")
|
||||
return
|
||||
|
||||
cidr_ips = download_cidr_lists(countries)
|
||||
manual_ips = read_manual_ips()
|
||||
combined_ips = cidr_ips + manual_ips
|
||||
|
||||
backup_last_update()
|
||||
|
||||
success = update_yaml_with_ips(combined_ips)
|
||||
if success:
|
||||
restart_adguard_container()
|
||||
|
||||
|
||||
def schedule_job():
|
||||
try:
|
||||
hour, minute = [int(x) for x in BLOCKLIST_CRON_TIME.split(":")]
|
||||
except Exception:
|
||||
logging.error(
|
||||
f"Invalid BLOCKLIST_CRON_TIME '{BLOCKLIST_CRON_TIME}', must be HH:MM. Defaulting to 06:00."
|
||||
)
|
||||
hour, minute = 6, 0
|
||||
|
||||
if BLOCKLIST_CRON_TYPE == "daily":
|
||||
schedule.every().day.at(f"{hour:02d}:{minute:02d}").do(update_blocklist)
|
||||
logging.info(f"Scheduled daily update at {hour:02d}:{minute:02d}")
|
||||
elif BLOCKLIST_CRON_TYPE == "weekly":
|
||||
day_names = {
|
||||
"mon": "monday",
|
||||
"tue": "tuesday",
|
||||
"wed": "wednesday",
|
||||
"thu": "thursday",
|
||||
"fri": "friday",
|
||||
"sat": "saturday",
|
||||
"sun": "sunday",
|
||||
}
|
||||
day = BLOCKLIST_CRON_DAY[:3]
|
||||
if day not in day_names:
|
||||
logging.error(
|
||||
f"Invalid BLOCKLIST_CRON_DAY '{BLOCKLIST_CRON_DAY}', must be one of {list(day_names)}. Defaulting to Monday."
|
||||
)
|
||||
day = "mon"
|
||||
getattr(schedule.every(), day_names[day]).at(f"{hour:02d}:{minute:02d}").do(
|
||||
update_blocklist
|
||||
)
|
||||
logging.info(
|
||||
f"Scheduled weekly update on {day_names[day].capitalize()} at {hour:02d}:{minute:02d}"
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
f"Invalid BLOCKLIST_CRON_TYPE '{BLOCKLIST_CRON_TYPE}', must be 'daily' or 'weekly'. Defaulting to daily."
|
||||
)
|
||||
schedule.every().day.at(f"{hour:02d}:{minute:02d}").do(update_blocklist)
|
||||
logging.info(f"Scheduled daily update at {hour:02d}:{minute:02d}")
|
||||
|
||||
|
||||
def main():
|
||||
logging.info("Starting blocklist scheduler...")
|
||||
backup_first_start()
|
||||
update_blocklist()
|
||||
schedule_job()
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+10
-5
@@ -5,11 +5,16 @@ services:
|
||||
container_name: adguard-cidre
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Europe/Paris # change to your timezone
|
||||
- BLOCK_COUNTRIES=cn,ru # choose countries listed IP to block. Full lists here https://github.com/vulnebify/cidre/tree/main/output/cidr/ipv4
|
||||
- BLOCKLIST_CRON=0 6 * * * # at 6:00 every days
|
||||
- DOCKER_API_URL=http://socket-proxy-adguard:2375 # docker socket proxy
|
||||
- ADGUARD_CONTAINER_NAME=adguardhome # adguard container name
|
||||
- PUID=${PUID} # user id the process runs as, matches ownership of the /adguard mount
|
||||
- PGID=${PGID} # group id the process runs as
|
||||
- TZ=${TZ} # change to your timezone
|
||||
- BLOCK_COUNTRIES=${BLOCK_COUNTRIES} # choose countries listed IP to block. Full lists here https://github.com/vulnebify/cidre/tree/main/output/cidr/ipv4
|
||||
- BLOCKLIST_CRON_TYPE=${BLOCKLIST_CRON_TYPE} # daily or weekly
|
||||
# if weekly, choose the day
|
||||
- BLOCKLIST_CRON_DAY=${BLOCKLIST_CRON_DAY}
|
||||
- BLOCKLIST_CRON_TIME=${BLOCKLIST_CRON_TIME}
|
||||
- DOCKER_API_URL=${DOCKER_API_URL} # docker socket proxy
|
||||
- ADGUARD_CONTAINER_NAME=${ADGUARD_CONTAINER_NAME} # adguard container name
|
||||
volumes:
|
||||
- /path/to/adguard/confdir:/adguard
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[entrypoint] %(message)s',
|
||||
stream=sys.stdout
|
||||
)
|
||||
|
||||
ADGUARD_YAML = Path("/adguard/AdGuardHome.yaml")
|
||||
FIRST_BACKUP = Path("/adguard/AdGuardHome.yaml.first-start.bak")
|
||||
|
||||
def setup_cron():
|
||||
cron_expr = os.getenv("BLOCKLIST_CRON", "0 6 * * *")
|
||||
cron_line = f"{cron_expr} root /usr/local/bin/update-blocklist.py\n"
|
||||
cron_dir = "/etc/crontabs"
|
||||
cron_file = f"{cron_dir}/root"
|
||||
|
||||
logging.info(f"Setting cron job: {cron_line.strip()}")
|
||||
|
||||
# Ensure cron directory exists
|
||||
os.makedirs(cron_dir, exist_ok=True)
|
||||
|
||||
with open(cron_file, "w") as f:
|
||||
f.write(cron_line)
|
||||
|
||||
def backup_first_start():
|
||||
if not FIRST_BACKUP.exists():
|
||||
logging.info("Creating first start backup...")
|
||||
FIRST_BACKUP.write_text(ADGUARD_YAML.read_text())
|
||||
else:
|
||||
logging.info("First start backup already exists.")
|
||||
|
||||
def run_initial_update():
|
||||
logging.info("Running initial update-blocklist.py script...")
|
||||
try:
|
||||
subprocess.run(
|
||||
["/usr/local/bin/update-blocklist.py"],
|
||||
check=True,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"Initial update script failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def start_cron_foreground():
|
||||
logging.info("Starting cron in foreground...")
|
||||
os.execvp("cron", ["cron", "-f"])
|
||||
|
||||
def main():
|
||||
# Check AdGuardHome.yaml exists
|
||||
if not ADGUARD_YAML.exists():
|
||||
logging.error(f"{ADGUARD_YAML} not found. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
backup_first_start()
|
||||
run_initial_update()
|
||||
setup_cron()
|
||||
start_cron_foreground()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
CYAN="\033[1;36m"
|
||||
NC="\033[0m"
|
||||
|
||||
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*"; }
|
||||
fail() { echo "$(date '+%Y-%m-%d %H:%M:%S') [!] $*" >&2; exit 1; }
|
||||
|
||||
print_banner() {
|
||||
version=$(cat VERSION 2>/dev/null || echo "unknown")
|
||||
title="AdGuard CIDRe - Version ${version}"
|
||||
lines="Source: https://git.djeex.fr/Djeex/adguard-cidre
|
||||
Mirror: https://github.com/Djeex/adguard-cidre"
|
||||
|
||||
width=${#title}
|
||||
old_ifs=$IFS
|
||||
IFS='
|
||||
'
|
||||
for l in $lines; do
|
||||
[ ${#l} -gt "$width" ] && width=${#l}
|
||||
done
|
||||
IFS=$old_ifs
|
||||
width=$((width + 2))
|
||||
|
||||
border=""
|
||||
i=0
|
||||
while [ "$i" -lt "$width" ]; do
|
||||
border="${border}─"
|
||||
i=$((i + 1))
|
||||
done
|
||||
printf "${CYAN}╭%s╮${NC}\n" "$border"
|
||||
|
||||
total_pad=$((width - ${#title}))
|
||||
left=$((total_pad / 2))
|
||||
right=$((total_pad - left))
|
||||
printf "${CYAN}│${NC}%*s%s%*s${CYAN}│${NC}\n" "$left" "" "$title" "$right" ""
|
||||
|
||||
printf "${CYAN}├%s┤${NC}\n" "$border"
|
||||
|
||||
IFS='
|
||||
'
|
||||
for l in $lines; do
|
||||
printf "${CYAN}│${NC} %-*s${CYAN}│${NC}\n" "$((width - 1))" "$l"
|
||||
done
|
||||
IFS=$old_ifs
|
||||
|
||||
printf "${CYAN}╰%s╯${NC}\n" "$border"
|
||||
}
|
||||
|
||||
print_banner
|
||||
|
||||
PUID=${PUID:-911}
|
||||
PGID=${PGID:-911}
|
||||
|
||||
case "$PGID" in
|
||||
''|*[!0-9]*) fail "PGID '$PGID' is not a valid numeric group id." ;;
|
||||
esac
|
||||
case "$PUID" in
|
||||
''|*[!0-9]*) fail "PUID '$PUID' is not a valid numeric user id." ;;
|
||||
esac
|
||||
|
||||
[ -d /adguard ] || fail "/adguard is not mounted — check the volume mapping in docker-compose.yml."
|
||||
|
||||
log "[i] Requested PUID=$PUID, PGID=$PGID"
|
||||
|
||||
log "[~] Checking group for GID $PGID..."
|
||||
GROUP_NAME=$(getent group "$PGID" | cut -d: -f1 || true)
|
||||
if [ -z "$GROUP_NAME" ]; then
|
||||
log "[→] No existing group with GID $PGID, creating 'appgroup'."
|
||||
addgroup -g "$PGID" appgroup || fail "Failed to create group with GID $PGID (addgroup exited $?)."
|
||||
GROUP_NAME=appgroup
|
||||
else
|
||||
log "[i] Reusing existing group '$GROUP_NAME' (GID $PGID)."
|
||||
fi
|
||||
log "[✓] Group ready: $GROUP_NAME"
|
||||
|
||||
log "[~] Checking user for UID $PUID..."
|
||||
USER_NAME=$(getent passwd "$PUID" | cut -d: -f1 || true)
|
||||
if [ -z "$USER_NAME" ]; then
|
||||
log "[→] No existing user with UID $PUID, creating 'appuser'."
|
||||
adduser -D -u "$PUID" -G "$GROUP_NAME" appuser || fail "Failed to create user with UID $PUID (adduser exited $?)."
|
||||
USER_NAME=appuser
|
||||
else
|
||||
log "[i] Reusing existing user '$USER_NAME' (UID $PUID)."
|
||||
fi
|
||||
log "[✓] User ready: $USER_NAME"
|
||||
|
||||
# Grant write access to the shared AdGuard config directory and to the files
|
||||
# this script manages, without touching anything else AdGuardHome owns in
|
||||
# there (its own db/certs/stats). AdGuardHome itself runs as root, so this is
|
||||
# a one-way grant: it keeps full access regardless of what we chown here.
|
||||
log "[~] Setting ownership of /adguard to $USER_NAME:$GROUP_NAME..."
|
||||
chown "$USER_NAME:$GROUP_NAME" /adguard || fail "chown on /adguard failed — check that the host directory permissions allow it."
|
||||
log "[✓] Ownership set on /adguard"
|
||||
|
||||
for f in AdGuardHome.yaml AdGuardHome.yaml.first-start.bak AdGuardHome.yaml.last-update.bak AdGuardHome.yaml.tmp; do
|
||||
if [ -e "/adguard/$f" ]; then
|
||||
chown "$USER_NAME:$GROUP_NAME" "/adguard/$f" || fail "chown on /adguard/$f failed."
|
||||
log "[✓] chown OK: /adguard/$f"
|
||||
fi
|
||||
done
|
||||
|
||||
log "[→] Dropping privileges to $USER_NAME:$GROUP_NAME and starting blocklist_scheduler.py"
|
||||
exec su-exec "$USER_NAME:$GROUP_NAME" python3 blocklist_scheduler.py "$@"
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"timezone": "Europe/Paris",
|
||||
"labels": ["bot"],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchManagers": ["pip_requirements"],
|
||||
"matchUpdateTypes": ["patch", "minor"],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchManagers": ["dockerfile"],
|
||||
"matchUpdateTypes": ["patch"],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["major"],
|
||||
"addLabels": ["major"]
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["minor"],
|
||||
"addLabels": ["minor"]
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"addLabels": ["bug"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
requests==2.34.2
|
||||
pyyaml==6.0.3
|
||||
schedule==1.2.2
|
||||
@@ -0,0 +1,10 @@
|
||||
line-length = 100
|
||||
|
||||
[lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[lint.isort]
|
||||
# See nvidia-stock-bot's ruff.toml for why this is pinned explicitly rather
|
||||
# than left to auto-detection.
|
||||
known-first-party = ["blocklist_scheduler"]
|
||||
@@ -0,0 +1,217 @@
|
||||
import pytest
|
||||
import schedule as schedule_lib
|
||||
import yaml
|
||||
|
||||
import blocklist_scheduler as bs
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, text="", status_code=200, raise_exc=None):
|
||||
self.text = text
|
||||
self.status_code = status_code
|
||||
self._raise_exc = raise_exc
|
||||
|
||||
def raise_for_status(self):
|
||||
if self._raise_exc:
|
||||
raise self._raise_exc
|
||||
|
||||
|
||||
def test_backup_first_start_creates_backup_when_missing(tmp_path, monkeypatch):
|
||||
adguard_yaml = tmp_path / "AdGuardHome.yaml"
|
||||
adguard_yaml.write_text("original: config\n")
|
||||
first_backup = tmp_path / "AdGuardHome.yaml.first-start.bak"
|
||||
|
||||
monkeypatch.setattr(bs, "ADGUARD_YAML", adguard_yaml)
|
||||
monkeypatch.setattr(bs, "FIRST_BACKUP", first_backup)
|
||||
|
||||
bs.backup_first_start()
|
||||
|
||||
assert first_backup.read_text() == "original: config\n"
|
||||
|
||||
|
||||
def test_backup_first_start_does_not_overwrite_existing_backup(tmp_path, monkeypatch):
|
||||
adguard_yaml = tmp_path / "AdGuardHome.yaml"
|
||||
adguard_yaml.write_text("new: config\n")
|
||||
first_backup = tmp_path / "AdGuardHome.yaml.first-start.bak"
|
||||
first_backup.write_text("pristine: original\n")
|
||||
|
||||
monkeypatch.setattr(bs, "ADGUARD_YAML", adguard_yaml)
|
||||
monkeypatch.setattr(bs, "FIRST_BACKUP", first_backup)
|
||||
|
||||
bs.backup_first_start()
|
||||
|
||||
assert first_backup.read_text() == "pristine: original\n"
|
||||
|
||||
|
||||
def test_backup_first_start_raises_if_adguard_yaml_missing(tmp_path, monkeypatch):
|
||||
adguard_yaml = tmp_path / "AdGuardHome.yaml"
|
||||
first_backup = tmp_path / "AdGuardHome.yaml.first-start.bak"
|
||||
|
||||
monkeypatch.setattr(bs, "ADGUARD_YAML", adguard_yaml)
|
||||
monkeypatch.setattr(bs, "FIRST_BACKUP", first_backup)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
bs.backup_first_start()
|
||||
|
||||
|
||||
# --- update_yaml_with_ips (pyyaml) ---
|
||||
|
||||
|
||||
def test_update_yaml_with_ips_writes_disallowed_clients(tmp_path, monkeypatch):
|
||||
adguard_yaml = tmp_path / "AdGuardHome.yaml"
|
||||
adguard_yaml.write_text("dns:\n bind_hosts:\n - 0.0.0.0\n")
|
||||
tmp_yaml = tmp_path / "AdGuardHome.yaml.tmp"
|
||||
|
||||
monkeypatch.setattr(bs, "ADGUARD_YAML", adguard_yaml)
|
||||
monkeypatch.setattr(bs, "TMP_YAML", tmp_yaml)
|
||||
|
||||
result = bs.update_yaml_with_ips(["1.2.3.0/24", "5.6.7.8"])
|
||||
|
||||
assert result is True
|
||||
data = yaml.safe_load(adguard_yaml.read_text())
|
||||
assert data["dns"]["disallowed_clients"] == ["1.2.3.0/24", "5.6.7.8"]
|
||||
assert not tmp_yaml.exists()
|
||||
|
||||
|
||||
def test_update_yaml_with_ips_missing_file_returns_false(tmp_path, monkeypatch):
|
||||
adguard_yaml = tmp_path / "AdGuardHome.yaml"
|
||||
|
||||
monkeypatch.setattr(bs, "ADGUARD_YAML", adguard_yaml)
|
||||
|
||||
assert bs.update_yaml_with_ips(["1.2.3.4"]) is False
|
||||
|
||||
|
||||
def test_update_yaml_with_ips_invalid_yaml_returns_false(tmp_path, monkeypatch):
|
||||
adguard_yaml = tmp_path / "AdGuardHome.yaml"
|
||||
adguard_yaml.write_text("key: [unclosed\n")
|
||||
|
||||
monkeypatch.setattr(bs, "ADGUARD_YAML", adguard_yaml)
|
||||
|
||||
assert bs.update_yaml_with_ips(["1.2.3.4"]) is False
|
||||
|
||||
|
||||
def test_update_yaml_with_ips_missing_dns_key_raises(tmp_path, monkeypatch):
|
||||
adguard_yaml = tmp_path / "AdGuardHome.yaml"
|
||||
adguard_yaml.write_text("some_other_key: true\n")
|
||||
|
||||
monkeypatch.setattr(bs, "ADGUARD_YAML", adguard_yaml)
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
bs.update_yaml_with_ips(["1.2.3.4"])
|
||||
|
||||
|
||||
# --- fetch_all_country_codes / download_cidr_lists / restart_adguard_container (requests) ---
|
||||
|
||||
|
||||
def test_fetch_all_country_codes_parses_codes(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
bs.requests, "get", lambda *a, **k: FakeResponse(text='COUNTRIES = ["FR", "DE", "US"]\n')
|
||||
)
|
||||
|
||||
assert bs.fetch_all_country_codes() == {"fr", "de", "us"}
|
||||
|
||||
|
||||
def test_fetch_all_country_codes_returns_empty_set_on_error(monkeypatch):
|
||||
def raise_error(*a, **k):
|
||||
raise bs.requests.exceptions.ConnectionError("boom")
|
||||
|
||||
monkeypatch.setattr(bs.requests, "get", raise_error)
|
||||
|
||||
assert bs.fetch_all_country_codes() == set()
|
||||
|
||||
|
||||
def test_download_cidr_lists_combines_successful_countries_and_skips_failures(monkeypatch):
|
||||
def fake_get(url, timeout=None):
|
||||
if "/fr.cidr" in url:
|
||||
return FakeResponse(text="1.1.1.0/24\n1.1.2.0/24\n")
|
||||
raise bs.requests.exceptions.ConnectionError("boom")
|
||||
|
||||
monkeypatch.setattr(bs.requests, "get", fake_get)
|
||||
|
||||
result = bs.download_cidr_lists(["fr", "de"])
|
||||
|
||||
assert result == ["1.1.1.0/24", "1.1.2.0/24"]
|
||||
|
||||
|
||||
def test_restart_adguard_container_success_does_not_raise(monkeypatch):
|
||||
monkeypatch.setattr(bs.requests, "post", lambda *a, **k: FakeResponse(status_code=204))
|
||||
|
||||
bs.restart_adguard_container()
|
||||
|
||||
|
||||
def test_restart_adguard_container_error_status_does_not_raise(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
bs.requests, "post", lambda *a, **k: FakeResponse(status_code=500, text="err")
|
||||
)
|
||||
|
||||
bs.restart_adguard_container()
|
||||
|
||||
|
||||
def test_restart_adguard_container_network_error_does_not_raise(monkeypatch):
|
||||
def raise_error(*a, **k):
|
||||
raise bs.requests.exceptions.ConnectionError("boom")
|
||||
|
||||
monkeypatch.setattr(bs.requests, "post", raise_error)
|
||||
|
||||
bs.restart_adguard_container()
|
||||
|
||||
|
||||
# --- schedule_job (schedule) ---
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_schedule():
|
||||
yield
|
||||
schedule_lib.clear()
|
||||
|
||||
|
||||
def test_schedule_job_daily(monkeypatch):
|
||||
monkeypatch.setattr(bs, "BLOCKLIST_CRON_TYPE", "daily")
|
||||
monkeypatch.setattr(bs, "BLOCKLIST_CRON_TIME", "06:00")
|
||||
|
||||
bs.schedule_job()
|
||||
|
||||
assert len(schedule_lib.jobs) == 1
|
||||
job = schedule_lib.jobs[0]
|
||||
assert job.unit == "days"
|
||||
assert str(job.at_time) == "06:00:00"
|
||||
assert job.job_func.func is bs.update_blocklist
|
||||
|
||||
|
||||
def test_schedule_job_weekly_valid_day(monkeypatch):
|
||||
monkeypatch.setattr(bs, "BLOCKLIST_CRON_TYPE", "weekly")
|
||||
monkeypatch.setattr(bs, "BLOCKLIST_CRON_TIME", "18:30")
|
||||
monkeypatch.setattr(bs, "BLOCKLIST_CRON_DAY", "wed")
|
||||
|
||||
bs.schedule_job()
|
||||
|
||||
job = schedule_lib.jobs[0]
|
||||
assert job.unit == "weeks"
|
||||
assert job.start_day == "wednesday"
|
||||
assert str(job.at_time) == "18:30:00"
|
||||
|
||||
|
||||
def test_schedule_job_weekly_invalid_day_defaults_to_monday(monkeypatch):
|
||||
monkeypatch.setattr(bs, "BLOCKLIST_CRON_TYPE", "weekly")
|
||||
monkeypatch.setattr(bs, "BLOCKLIST_CRON_DAY", "xxx")
|
||||
|
||||
bs.schedule_job()
|
||||
|
||||
assert schedule_lib.jobs[0].start_day == "monday"
|
||||
|
||||
|
||||
def test_schedule_job_invalid_time_defaults_to_six_am(monkeypatch):
|
||||
monkeypatch.setattr(bs, "BLOCKLIST_CRON_TYPE", "daily")
|
||||
monkeypatch.setattr(bs, "BLOCKLIST_CRON_TIME", "not-a-time")
|
||||
|
||||
bs.schedule_job()
|
||||
|
||||
assert str(schedule_lib.jobs[0].at_time) == "06:00:00"
|
||||
|
||||
|
||||
def test_schedule_job_invalid_type_defaults_to_daily(monkeypatch):
|
||||
monkeypatch.setattr(bs, "BLOCKLIST_CRON_TYPE", "bogus")
|
||||
|
||||
bs.schedule_job()
|
||||
|
||||
assert schedule_lib.jobs[0].unit == "days"
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[update-blocklist] %(levelname)s: %(message)s',
|
||||
stream=sys.stdout,
|
||||
)
|
||||
|
||||
ADGUARD_YAML = Path("/adguard/AdGuardHome.yaml")
|
||||
FIRST_BACKUP = Path("/adguard/AdGuardHome.yaml.first-start.bak")
|
||||
LAST_CRON_BACKUP = Path("/adguard/AdGuardHome.yaml.last-cron.bak")
|
||||
TMP_YAML = ADGUARD_YAML.parent / (ADGUARD_YAML.name + ".tmp")
|
||||
MANUAL_IPS_FILE = Path("/adguard/manually_blocked_ips.conf")
|
||||
CIDR_BASE_URL = "https://raw.githubusercontent.com/vulnebify/cidre/main/output/cidr/ipv4"
|
||||
COUNTRIES = os.getenv("BLOCK_COUNTRIES", "")
|
||||
|
||||
def backup_files():
|
||||
if not FIRST_BACKUP.exists():
|
||||
logging.info(f"Creating first-start backup: {FIRST_BACKUP}")
|
||||
FIRST_BACKUP.write_text(ADGUARD_YAML.read_text())
|
||||
else:
|
||||
logging.info("First-start backup already exists, skipping.")
|
||||
|
||||
logging.info(f"Creating last-cron backup: {LAST_CRON_BACKUP}")
|
||||
LAST_CRON_BACKUP.write_text(ADGUARD_YAML.read_text())
|
||||
|
||||
def download_cidr_lists(countries):
|
||||
combined_ips = []
|
||||
for code in countries:
|
||||
url = f"{CIDR_BASE_URL}/{code.lower()}.cidr"
|
||||
logging.info(f"Downloading CIDR list for {code} from {url}")
|
||||
try:
|
||||
r = requests.get(url, timeout=30)
|
||||
r.raise_for_status()
|
||||
ips = r.text.strip().splitlines()
|
||||
logging.info(f"Downloaded {len(ips)} CIDR entries for {code}")
|
||||
combined_ips.extend(ips)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to download {code}: {e}")
|
||||
return combined_ips
|
||||
|
||||
def read_manual_ips():
|
||||
if MANUAL_IPS_FILE.exists():
|
||||
logging.info(f"Reading manual IPs from {MANUAL_IPS_FILE}")
|
||||
valid_ips = []
|
||||
with MANUAL_IPS_FILE.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Simple check for IPv4 or IPv4 CIDR format
|
||||
if line and line.count('.') == 3:
|
||||
valid_ips.append(line)
|
||||
logging.info(f"Added {len(valid_ips)} manual IP entries")
|
||||
return valid_ips
|
||||
else:
|
||||
logging.info("Manual IPs file does not exist, skipping.")
|
||||
return []
|
||||
|
||||
def update_yaml_with_ips(ips):
|
||||
output_lines = []
|
||||
inside_disallowed = False
|
||||
disallowed_indent = ""
|
||||
|
||||
with ADGUARD_YAML.open() as f:
|
||||
lines = f.readlines()
|
||||
|
||||
for line in lines:
|
||||
stripped = line.lstrip()
|
||||
indent = line[:len(line) - len(stripped)]
|
||||
|
||||
if stripped.startswith("disallowed_clients:"):
|
||||
# Capture the indentation of the disallowed_clients key
|
||||
disallowed_indent = indent
|
||||
|
||||
# Replace entire line with just 'disallowed_clients:' (remove any [])
|
||||
output_lines.append(f"{disallowed_indent}disallowed_clients:")
|
||||
|
||||
# Add all IPs indented 2 spaces more than disallowed_clients
|
||||
formatted_ips = [f"{disallowed_indent} - {ip}" for ip in ips]
|
||||
output_lines.extend(formatted_ips)
|
||||
|
||||
inside_disallowed = True
|
||||
continue
|
||||
|
||||
if inside_disallowed:
|
||||
# We skip all old lines inside disallowed_clients block.
|
||||
# The block ends when we find a line with indentation
|
||||
# less than or equal to disallowed_indent but not the key line itself.
|
||||
# To detect end of block, compare indent length:
|
||||
if len(indent) <= len(disallowed_indent) and stripped != "":
|
||||
inside_disallowed = False
|
||||
output_lines.append(line.rstrip("\n"))
|
||||
else:
|
||||
# skip this line (old disallowed_clients content)
|
||||
continue
|
||||
else:
|
||||
output_lines.append(line.rstrip("\n"))
|
||||
|
||||
# Write temp file in same directory to avoid cross-device rename errors
|
||||
with TMP_YAML.open("w") as f:
|
||||
f.write("\n".join(output_lines) + "\n")
|
||||
|
||||
TMP_YAML.replace(ADGUARD_YAML)
|
||||
logging.info(f"Updated {ADGUARD_YAML} with new disallowed clients list.")
|
||||
|
||||
|
||||
def restart_adguard_container():
|
||||
docker_api_url = os.getenv("DOCKER_API_URL", "http://socket-proxy-adguard:2375")
|
||||
container_name = os.getenv("ADGUARD_CONTAINER_NAME", "adguardhome")
|
||||
restart_url = f"{docker_api_url}/containers/{container_name}/restart"
|
||||
|
||||
logging.info(f"Restarting AdGuard container '{container_name}'...")
|
||||
try:
|
||||
resp = requests.post(restart_url, timeout=10)
|
||||
if resp.status_code == 204:
|
||||
logging.info("AdGuard container restarted successfully.")
|
||||
else:
|
||||
logging.error(f"Failed to restart container: {resp.status_code} {resp.text}")
|
||||
except Exception as e:
|
||||
logging.error(f"Error restarting container: {e}")
|
||||
|
||||
def main():
|
||||
if not ADGUARD_YAML.exists():
|
||||
logging.error(f"{ADGUARD_YAML} not found, exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
if not COUNTRIES:
|
||||
logging.error("No countries specified in BLOCK_COUNTRIES environment variable, exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
backup_files()
|
||||
|
||||
countries_list = [c.strip() for c in COUNTRIES.split(",") if c.strip()]
|
||||
cidr_ips = download_cidr_lists(countries_list)
|
||||
manual_ips = read_manual_ips()
|
||||
|
||||
combined_ips = cidr_ips + manual_ips
|
||||
|
||||
update_yaml_with_ips(combined_ips)
|
||||
|
||||
restart_adguard_container()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user