commit 52e6b4a9a0012fc0dcdbd93bc4580072f75dd78e Author: Ettore Dreucci Date: Sun Oct 5 19:32:16 2025 +0200 First version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bf780b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4655b80 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY src/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/netflix-asn.py . +COPY src/crontab.txt . + +RUN chmod 0644 crontab.txt \ + && apt-get update && apt-get install -y cron \ + && crontab crontab.txt + +CMD printenv > /etc/environment && cron -f \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f8e892 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# netflix-asn + +A small **Python utility** that fetches IPv4/IPv6 prefixes announced by one or more ASNs (via the [BGPView API](https://bgpview.io/api)) and ensures those prefixes are present in a MikroTik **IP firewall address-list**. + +It’s designed to run inside **Docker** — using a `Dockerfile` and `docker-compose.yml`. + +## Features + +- Fetches all IPv4/IPv6 prefixes announced by one or more ASNs. +- Adds missing prefixes to a MikroTik address-list. +- Skips existing entries to avoid duplicates. +- Logs progress and errors clearly. +- Suitable for manual or scheduled execution. + +## Quick Start + +1. Create a `.env` file (see [Example .env](#example-env)). +2. Build the Docker image: + + ```bash + docker-compose build + ``` + +3. Run the container: + + ```bash + docker-compose up -d + ``` + +4. View logs: + + ```bash + docker-compose logs -f asn-syncer + ``` + +## Example `.env` + +```env +# Target ASN(s) — default is AS2906 (Netflix) +ASN=AS55095,AS40027,AS394406,AS2906 + +# MikroTik API connection +MIKROTIK_HOST=192.168.88.1 +USERNAME=admin +PASSWORD=verysecret + +# Name of the address-list on the MikroTik +ADDRESS_LIST_NAME=Netflix +``` + +> **Tip:** Keep your `.env` file out of version control. +> Use Docker secrets or a secure secrets manager for production deployments. + +## Environment Variables + +| Variable | Required | Default | Description | +|---------------------|----------|-----------|------------------------------------------------------| +| `ASN` | No | `AS2906` | Comma-separated list of ASNs to fetch prefixes from. | +| `MIKROTIK_HOST` | Yes | — | IP or hostname of the MikroTik device. | +| `USERNAME` | Yes | — | MikroTik API username. | +| `PASSWORD` | Yes | — | MikroTik API password. | +| `ADDRESS_LIST_NAME` | No | `Netflix` | MikroTik address-list name to add entries to. | + +> The script sets a fixed `timeout=24:00:00` for each address-list entry. +> Modify the script if you prefer permanent entries. + +## How It Works + +1. The script loads configuration from environment variables. +2. For each ASN, it queries: + ``` + https://api.bgpview.io/asn//prefixes + ``` +3. It collects all IPv4/IPv6 prefixes and removes duplicates. +4. Connects to the MikroTik API using [`librouteros`](https://pypi.org/project/librouteros/). +5. For each prefix: + - Skips it if it already exists in the address-list. + - Otherwise adds it with: + - `timeout=24:00:00` + - `comment="Added from ASN"` + +## Logging & Exit Codes + +| Type | Description | +|-----------------|------------------------------------------------------------| +| **INFO** | Normal progress messages (connection, added subnets, etc). | +| **DEBUG** | Skipped subnets that already exist. | +| **ERROR/FATAL** | Connection or API failure. | + +| Exit Code | Meaning | +|------------|--------------------------------------------------------------| +| `0` | Success | +| `1` | Fatal error (missing vars, API failure, or connection error) | + +## Security Notes + +- Never commit credentials or `.env` files to Git. +- Use dedicated API accounts on MikroTik with minimal permissions. +- Run the container within a trusted network or over a secure VPN. +- Use `Docker secrets` for sensitive information in production. + +## License + +This project is provided under the [MIT License](LICENSE) — free for personal and commercial use. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0f82cb3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.8" +services: + netflix-asn: + build: . + container_name: netflix-asn + env_file: + - .env + volumes: + - ./logs:/var/log + restart: unless-stopped +networks: {} diff --git a/src/crontab.txt b/src/crontab.txt new file mode 100644 index 0000000..af8c0c7 --- /dev/null +++ b/src/crontab.txt @@ -0,0 +1,2 @@ +SHELL=/bin/bash +0 * * * * /usr/local/bin/python3 /app/netflix-asn.py >> /var/log/cron.log 2>&1 \ No newline at end of file diff --git a/src/netflix-asn.py b/src/netflix-asn.py new file mode 100644 index 0000000..2fe8a81 --- /dev/null +++ b/src/netflix-asn.py @@ -0,0 +1,111 @@ +import os +import sys +import time +import logging +import requests +from typing import List, Set +from librouteros import connect +from librouteros.query import Key +from librouteros.exceptions import TrapError + +# Constants and config +DEFAULT_ASN = "AS2906" # Netflix ASN +TIMEOUT = "24:00:00" + +ASN = os.getenv("ASN", DEFAULT_ASN).split(',') +MIKROTIK_HOST = os.getenv("MIKROTIK_HOST") +USERNAME = os.getenv("USERNAME") +PASSWORD = os.getenv("PASSWORD") +ADDRESS_LIST_NAME = os.getenv("ADDRESS_LIST_NAME", "Netflix") + +def get_subnets_from_asn(asn: str) -> List[str]: + """ + Queries BGPView API to get all IPv4 prefixes announced by an ASN. + """ + logging.info(f"Fetching prefixes for ASN {asn}...") + url = f"https://api.bgpview.io/asn/{asn}/prefixes" + + try: + response = requests.get(url) + response.raise_for_status() + except requests.RequestException as e: + raise RuntimeError(f"Failed to fetch ASN data: {e}") from e + + data = response.json() + prefixes = [item['prefix'] for item in data['data']['ipv4_prefixes']] + logging.info(f"Found {len(prefixes)} IPv4 prefixes for ASN {asn}.") + return prefixes + +def get_subnets_from_asns(asns: List[str]) -> List[str]: + """ + Fetch prefixes from multiple ASNs. + """ + subnets: Set[str] = set() + for asn in asns: + subnets.update(get_subnets_from_asn(asn)) + time.sleep(2) # Respect API rate limits + return list(subnets) + +def subnet_exists(address_list, subnet: str, list_name: str) -> bool: + """ + Check if a subnet already exists in the MikroTik address list. + """ + entries = address_list.select().where(Key('address') == subnet, Key('list') == list_name) + return any(entries) + +def add_subnet_address_list(address_list, subnet: str, list_name: str) -> bool: + """ + Add a single subnet to the MikroTik address list. + """ + try: + address_list.add(address=subnet, list=list_name, timeout=TIMEOUT, comment="Added from ASN") + return True + except TrapError as err: + logging.error(f"Failed to add subnet {subnet}: {err}") + return False + +def add_subnets_address_list(address_list, subnets: List[str], list_name: str) -> int: + """ + Add multiple subnets to the MikroTik address list. + """ + added = 0 + for subnet in subnets: + if subnet_exists(address_list, subnet, list_name): + logging.debug(f"[SKIP] {subnet} already exists.") + continue + if add_subnet_address_list(address_list, subnet, list_name): + logging.info(f"[ADD] {subnet}") + added += 1 + return added + +def main(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M" + ) + + if not MIKROTIK_HOST or not USERNAME or not PASSWORD: + logging.fatal("Missing MikroTik connection info (check env vars)") + sys.exit(1) + + logging.info(f"Connecting to MikroTik at {MIKROTIK_HOST}...") + try: + api = connect(host=MIKROTIK_HOST, username=USERNAME, password=PASSWORD) + except Exception as e: + logging.fatal(f"Failed to connect to MikroTik: {e}", exc_info=True) + sys.exit(1) + + address_list = api.path('ip', 'firewall', 'address-list') + + try: + subnets = get_subnets_from_asns(ASN) + except Exception as e: + logging.fatal(f"Failed to get subnets from ASN: {e}", exc_info=True) + sys.exit(1) + + added = add_subnets_address_list(address_list, subnets, ADDRESS_LIST_NAME) + logging.info(f"Done. Added {added} new subnets.") + +if __name__ == "__main__": + main() diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..4126811 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,2 @@ +requests +librouteros \ No newline at end of file