diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/SLAACsense.iml b/.idea/SLAACsense.iml
new file mode 100644
index 0000000..d0876a7
--- /dev/null
+++ b/.idea/SLAACsense.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..a6218fe
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..0686315
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..556bb0d
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,7 @@
+FROM python:latest
+LABEL authors="notherealmarco"
+WORKDIR /app/
+COPY . .
+RUN pip install -r requirements.txt
+
+ENTRYPOINT ["python3", "/app/main.py"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 386e16e..9d96f9a 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,36 @@
-# SLAACsense
\ No newline at end of file
+# SLAACsense
+
+SLAACsense streamlines the process of configuring DNS records on OPNsense routers using Technitium DNS Server.
+
+Designed to enhance network management, the tool automatically defines DNS A, AAAA, and PTR records for each device connected to the network based on its DHCPv4 hostname.
+
+By leveraging the DHCPv4 lease information and mapping it to the MAC address, the tool navigates the NDP table to retrieve IPv6 addresses associated with each host. Subsequently, it configures the DNS records accordingly, providing a seamless solution for maintaining an up-to-date and accurate DNS configuration.
+
+## Usage:
+
+Define the environment variables in the docker-compose file, then run: `docker compose up -d`
+
+### Environment variables:
+
+| Variable Name | Description | Example Value |
+|-----------------------|------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|
+| `OPNSENSE_URL` | The base URL of your OPNsense instance | http://192.168.1.1 (required)
+| `OPNSENSE_API_KEY` | OPNsense API key | `your_opnsense_api_key` (required) |
+| `OPNSENSE_API_SECRET` | OPNsense API secret | `a_very_secret_token` (required) |
+| `TECHNITIUM_URL` | The base URL of your Technitium DNS instance | `dns.myawesomehome.home.arpa` (required) |
+| `TECHNITIUM_TOKEN` | Technitium DNS token | `another_very_secret_token` (required) |
+| `DNS_ZONE_SUBNETS` | Comma separated DNS zones and IPv4 subnet | `192.168.1.0/24=lan.home.arpa,192.168.2.0/24=dmz.home.arpa` (required) |
+| `DO_V4` | If set to true, A records will be configured, otherwise only AAAA records are configured | `false` (defaults to false) |
+| `VERIFY_HTTPS` | Verify OPNsense and Technitium's SSL certificates | `true` (defaults to true) |
+| `CLOCK` | Interval between updates (in seconds) | `30` (defaults to 30) |
+
+### Note
+You have to create the corresponding DNS zones in the Technitium dashboard, you can configure them as primary or conditional forwarder zones.
+
+### Contributing:
+I welcome contributions! Feel free to submit issues, feature requests, or pull requests.
+
+For example, you may add the support for other DNS servers, like Bind, and other routing platforms, like pfSense and OpenWrt.
+
+### License:
+This tool is released under the MIT license. See the LICENSE file for details.
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..3261f3e
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,16 @@
+version: '3.6'
+services:
+ slaacsense:
+ build: .
+ container_name: slaacsense
+ restart: unless-stopped
+ environment:
+ - OPNSENSE_URL=${OPNSENSE_URL}
+ - OPNSENSE_API_KEY=${OPNSENSE_API_KEY}
+ - OPNSENSE_API_SECRET=${OPNSENSE_API_SECRET}
+ - TECHNITIUM_URL=${TECHNITIUM_URL}
+ - TECHNITIUM_TOKEN=${TECHNITIUM_TOKEN}
+ - DNS_ZONE_SUBNETS=${DNS_ZONE_SUBNETS}
+ - DO_V4=${DO_V4}
+ - VERIFY_HTTPS=${VERIFY_HTTPS}
+ - CLOCK=${CLOCK}
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..bbe17ee
--- /dev/null
+++ b/main.py
@@ -0,0 +1,142 @@
+import logging
+import os
+import time
+import requests
+import ipaddress
+import urllib3
+
+OPNSENSE_URL = ""
+OPNSENSE_API_KEY = ""
+OPNSENSE_API_SECRET = ""
+TECHNITIUM_URL = ""
+TECHNITIUM_TOKEN = ""
+IPV6_PREFIX = ""
+DNS_ZONE_SUBNETS = ""
+DO_V4 = False
+VERIFY_HTTPS = False
+CLOCK = 30
+
+
+def get_opnsense_data(path):
+ r = requests.get(url=OPNSENSE_URL + path, verify=VERIFY_HTTPS, auth=(OPNSENSE_API_KEY, OPNSENSE_API_SECRET))
+ if r.status_code != 200:
+ logging.error("Error occurred" + str(r.status_code) + ": " + r.text)
+ return None
+ return r.json()
+
+
+def get_ndp():
+ return get_opnsense_data("/api/diagnostics/interface/search_ndp")
+
+
+def get_dhcp4_leases():
+ return get_opnsense_data("/api/dhcpv4/leases/searchLease")
+
+
+def build_matches(ndp, leases):
+ matches = set()
+ for e in leases["rows"]:
+ ip6s = tuple(x["ip"].split("%")[0] for x in ndp["rows"] if x["mac"] == e["mac"])
+ if len(ip6s) == 0:
+ continue
+ matches.add((e["address"], ip6s, e["hostname"]))
+ return matches
+
+
+def find_zone(zones, ip4):
+ for zone in zones:
+ if ip4 in zone[0]: return zone[1]
+ return None
+
+
+def make_record(zones, match):
+ zone = find_zone(zones, ipaddress.ip_address(match[0]))
+ if zone is None:
+ logging.warning("Could not find a DNS zone for " + match[0])
+ return
+
+ ip4 = match[0]
+ ip6s = [ipaddress.ip_address(x) for x in match[1]]
+ hostname = match[2]
+
+ if hostname == "":
+ logging.warning("no hostname found for " + match[0])
+ return
+
+ for ip6 in ip6s:
+ v6path = "/api/zones/records/add?token=" + TECHNITIUM_TOKEN + "&domain=" + hostname + "." + zone + "&type=AAAA&ttl=1&overwrite=true&ptr=true&ipAddress=" + ip6.exploded
+ logging.info("Inserting AAAA: " + hostname + "." + zone + " " + ip6.compressed)
+ r = requests.get(url=TECHNITIUM_URL + v6path, verify=VERIFY_HTTPS)
+ if r.status_code != 200:
+ logging.error("Error occurred" + str(r.status_code) + ": " + r.text)
+ continue
+
+ if DO_V4:
+ v4path = "/api/zones/records/add?token=" + TECHNITIUM_TOKEN + "&domain=" + hostname + "." + zone + "&type=A&ttl=1&overwrite=true&ptr=true&ipAddress=" + ip4
+ logging.info("Inserting A: " + hostname + "." + zone + " " + ip4)
+ r = requests.get(url=TECHNITIUM_URL + v4path, verify=VERIFY_HTTPS)
+ if r.status_code != 200:
+ logging.error("Error occurred" + str(r.status_code) + ": " + r.text)
+
+
+def run():
+ if not VERIFY_HTTPS:
+ urllib3.disable_warnings()
+
+ previous_matches = set()
+ zones = []
+ for z in DNS_ZONE_SUBNETS.split(","):
+ zone = z.split("=")
+ zones.append((ipaddress.ip_network(zone[0]), zone[1]))
+
+ while True:
+ ndp = get_ndp()
+ if ndp is None:
+ logging.error("Error retrieving NDP table")
+ continue
+ leases = get_dhcp4_leases()
+ if leases is None:
+ logging.error("Error retrieving DHCPv4 leases")
+ continue
+ matches = build_matches(ndp, leases)
+ new_matches = matches - previous_matches
+ previous_matches = matches
+
+ for match in new_matches:
+ make_record(zones, match)
+ time.sleep(CLOCK)
+
+
+def verify_env() -> bool:
+ if not OPNSENSE_URL: return False
+ if not OPNSENSE_API_KEY: return False
+ if not OPNSENSE_API_SECRET: return False
+ if not TECHNITIUM_URL: return False
+ if not TECHNITIUM_TOKEN: return False
+ if not DNS_ZONE_SUBNETS: return False
+ return True
+
+
+if __name__ == "__main__":
+ logging.getLogger().setLevel(os.getenv("LOG_LEVEL", "INFO"))
+ logging.info("loading environment...")
+
+ OPNSENSE_URL = os.getenv("OPNSENSE_URL", None)
+ OPNSENSE_API_KEY = os.getenv("OPNSENSE_API_KEY", None)
+ OPNSENSE_API_SECRET = os.getenv("OPNSENSE_API_SECRET", None)
+ TECHNITIUM_URL = os.getenv("TECHNITIUM_URL", None)
+ TECHNITIUM_TOKEN = os.getenv("TECHNITIUM_TOKEN", None)
+ DNS_ZONE_SUBNETS = os.getenv("DNS_ZONE_SUBNETS", None)
+ DO_V4 = (os.getenv("DO_V4", "false").lower() == "true")
+ VERIFY_HTTPS = (os.getenv("VERIFY_HTTPS", "true").lower() == "true")
+ CLOCK = int(os.getenv("CLOCK", "30"))
+
+ if not verify_env():
+ logging.error("Missing mandatory environment variables")
+ exit(0)
+
+ logging.info("Starting SLAACsense...")
+ logging.info("OPNSENSE_URL: {}".format(OPNSENSE_URL))
+ logging.info("TECHNITIUM_URL: {}".format(TECHNITIUM_URL))
+ logging.info("VERIFY_HTTPS: {}".format(VERIFY_HTTPS))
+ run()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..345bc27
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+requests
+urllib3
\ No newline at end of file