MSDA is the continuation of SATA-AI (Smart AP Traffic Analyzer). MikroTik remains the main router; the name MSDA makes that explicit. MSDA builds a more complete DNS data pipeline: AI domain classification, Slack and Telegram notifications, anomaly detection, automatic blocklist sync, optional DNS blocking with AdGuard Home, daily and weekly reports, and a web dashboard with login and user roles.
Base architecture:
Client → MikroTik (DNS + log) ──→ AdGuard Home (optional: blocks selected domains) ──→ Public DNS
│
└─ DNS log → Vector → n8n → Redis + MongoDB → Backend (Go) → Dashboard (Next.js)
└→ Slack / Telegram
- End-to-end pipeline from MikroTik DNS logs to structured data in MongoDB, including a raw data archive (
dns_raw_logs). - More economical and accurate AI domain classification. Domains are grouped by parent domain, results are cached for 30 days (
dns_info), and the AI is called in batches (20 domains per request, 4 second delay, 3 retries on failure). IP, PTR and ISP data come from Cloudflare DoH and RDAP, not from AI guesses. A new domain is removed from the queue only after its result is stored.
- The blocklist is managed from the dashboard and matched against the domain and its parent domains (for example
a.b.slot.commatches the entryslot.com). - Automatic blocklist sync from public feeds every day (URLhaus and Phishing Army enabled; StevenBlack available but disabled). Feed entries expire after 7 days if they disappear from the feed; manual entries and entries disabled by an admin are never overwritten.
- Two modes per blocklist entry:
alert(default, also used when the field is missing): monitor only. A match raises an alert to Slack, Telegram and the dashboard, but the client can still open the domain.enforce: blocked at DNS (the domain and all its subdomains) and still raises an alert.
- Blocking is done by AdGuard Home, an optional Docker container (Compose profile
enforcement, off by default). MikroTik stays the clients' first resolver and forwards queries to AdGuard as its only upstream. The existing NAT redirect keeps forcing clients to use the MikroTik DNS on port 53. - Block-attempt alerts. MikroTik still logs every query together with the client IP, so the existing pipeline can produce "client X tried to open blocked domain Y" alerts. Only the alert wording changes:
enforceentries are reported as blocked,alertentries as monitored. The AdGuard query log is not needed for this. - Safe sync. MongoDB (
dns_blacklist) remains the single source of truth. The Go backend builds the AdGuard rules (||domain^) fromenforceentries and replaces the whole rule set (idempotent). Sync runs after blocklist changes, on a timer and at startup. Group entries are expanded into alldns_infodomains of that group, allowlist entries become exceptions, and entries that are inactive or expired are left out. The dashboard shows a sync status per entry (Synced, Pending, Error), and only admins can change the mode. - Safeguards. Strict domain validation (a value can never inject an AdGuard modifier or a second rule), a never-block list that protects a domain, its subdomains and its parents, rejection of TLDs and public suffixes, retyping the domain to confirm for widely used or very short domains, and rejection of the unsafe blocking modes
defaultandrefused(usenull_ipornxdomain). Mode changes, manual sync and the protection switch are written to the audit log. - Known limits:
- The MikroTik DNS cache can delay a new block until the TTL expires.
- Clients using DNS-over-HTTPS/TLS can bypass blocking (mitigation: block TCP/UDP 853 and use the public DoH resolver preset).
- AdGuard sees the MikroTik IP as its only "client", so per-client attribution always comes from MikroTik logs.
- There is no block page: the answer is
0.0.0.0or NXDOMAIN. - If AdGuard goes down, the behavior depends on the MikroTik script: with a backup DNS (fail-open) the network keeps working but blocking stops; without one (fail-closed) DNS stops. The script in
infrastructure/mikrotik/mikrotik-adguard.rscships with no backup DNS, so it is fail-closed untilbackupDNSis set. With fail-open, AdGuard health alerts are mandatory. - Group-level entries (
blocked_level: "group", no domain) are matched in theFilternode through the domain's group indns_info, the same way the backend expands them for DNS blocking. Domains that are not yet classified have no group, so they are not matched until the AI lane has classified them. SetMATCH_GROUP_ENTRIES = falseinFilterto restore the old behavior (group entries ignored). enforcedis true only when the entry is inenforcemode and its rule is synced to AdGuard (enforce_status = synced). It cannot know whether AdGuard is up at that moment; the AdGuard monitor lane covers that.
- Status. Implemented end to end and optional (off by default; see
docs/adguard.md): backend, dashboard, infrastructure and the n8n changes in section 2.11. Not implemented: the daily-report totals for blocked attempts and a dedicatedblock_attemptnotification type.
- One notification gateway (
Notify) for Slack and Telegram, configured throughnotification_settings(active channels, severity level, quiet hours;criticalalerts always pass). If the settings do not exist, notifications go to Slack and to Telegram (whenMSDA_TELEGRAM_CHAT_IDis set). - Blocklist alerts are grouped per client and sent in batches every minute; duplicates are filtered per domain + client.
- Telegram messages use HTML format with automatic escaping and are split when longer than 3,800 characters. A failure on one channel does not fail the other.
- Ingestion heartbeat: a
criticalalert when no data arrives for more than 15 minutes (configurable), once per outage, plus a recovery message. - Anomaly detection every 15 minutes: volume spikes per AP and per client (compared with the 7-day baseline for the same hour), high-entropy domains (DGA indicator), very long domains, use of public DoH/DoT resolvers, and spikes of new domains.
- Error Handler: workflow failures are reported as
criticalnotifications.
- Daily report (00:05 WIB) with a 7-day trend and AI analysis.
- Weekly report (Monday 00:10) comparing the last 7 days with the previous 7 days.
- Commands
/status,/top,/block <domain>and/unblock <domain>. Only registered user IDs are served, there is a limit of 10 commands per minute per user, domains are strictly validated, and/blockalways asks for button confirmation. - Enforcement on MikroTik (RouterOS) is available as an option and is disabled by default.
- Login and user management with
adminandviewerroles; changing data is admin-only. - Real-time network and node status (online/offline, active clients), plus Notifications, Reports and Blocklist pages, and Storage & cleanup for data retention.
- On the Blocklist page: a Monitor / Block DNS mode per entry (single and bulk), a sync status chip, a DNS Enforcement panel (connection status, protection switch, rule count, blocked today, Sync now), a "check a domain" tool and a visible never-block list. When the feature is off, the panel only explains how to enable it.
- Access results are labeled honestly: Blocked is shown only when the domain was actually blocked at DNS (
enforced = true). Entries that only matched the blocklist show as Matched blocklist (monitor). - The backend receives notifications from n8n through a webhook, stores them and broadcasts them to the dashboard over WebSocket.
Main workflow: MSDA - DNS Monitoring Workflow v10 (155 nodes, including 10 notes/sticky notes). It contains eleven lanes in a single workflow:
| # | Lane | Trigger | Purpose |
|---|---|---|---|
| 1 | Ingestion | Webhook POST /webhook/dns-scan-webhook |
receive logs from Vector, queue domains, detect blocklist matches, store access |
| 2 | AI classification | Cron every 2 hours | analyze queued domains and store them in dns_info |
| 3 | Alert delivery | Cron every 1 minute | send pending blocklist alerts, grouped per client |
| 4 | Notification gateway (Notify) |
called by other lanes | send messages to Slack and Telegram |
| 5 | Daily report | Cron 00:05 WIB (+ manual test) | daily statistics, trend, AI analysis |
| 6 | Weekly report | Cron Monday 00:10 (+ manual test) | weekly comparison with AI |
| 7 | Heartbeat | Cron every 5 minutes | monitor incoming data |
| 8 | Anomaly detection | Cron every 15 minutes | detect suspicious patterns |
| 9 | Feed sync | Cron daily 03:00 | refresh the blocklist from public feeds |
| 10 | Telegram commands | Telegram Trigger | /status, /top, /block, /unblock |
| 11 | AdGuard monitor | Cron every 5 minutes | read AdGuard health from the backend and alert on problems |
There is also an Error Handler (Error Trigger) set as the error workflow in the workflow settings.
Redis (all keys are strings with a TTL):
| Key | TTL | Purpose |
|---|---|---|
queue_{domain} |
6 hours | domain queue for AI classification |
dns_notification_{domain}_{client_ip} |
20 minutes | alert dedup toward the backend |
slack_pending_{domain}_{client_ip} |
1 hour | alerts waiting to be sent |
slack_sent_{domain}_{client_ip} |
24 hours | marker that the alert was sent |
heartbeat_down |
24 hours | marker that an ingestion outage is in progress |
adguard_down |
24 hours | marker that an AdGuard problem is in progress |
| anomaly dedup keys | per rule | prevent repeated anomaly alerts |
MongoDB: ip_access_dns (client access), dns_raw_logs (raw archive), dns_info (classification results), dns_blacklist, dns_daily_stats, dns_ai_reports, notification_settings. With enforcement enabled the backend also keeps adguard_sync_state (one document, _id: "main").
flowchart TD
A["Webhook (Header Auth)"] --> B["Raw: Build Document"] --> C[("dns_raw_logs")]
A --> D["Split Out per domain"]
D --> E["Find dns_info"]
E --> F["Redis Get: queue"]
F --> G["Filter Set as queue"]
G --> H{"Not in queue yet?"}
H -->|yes| I["Redis Set: queue"]
G --> J["Blacklist: Build Query"]
J --> K["Find dns_blacklist"]
K --> L["Redis Keys: dns_notification, slack_sent, slack_pending"]
L --> M["Filter: match blocklist and dedup"]
M --> N[("ip_access_dns")]
M --> O{"Blocklist match?"}
O -->|yes| P["Send To MSDA Backend"]
O -->|yes| Q["Redis Set: dns_notification"]
M --> R{"Send to Slack/Telegram?"}
R -->|yes| S["Redis Set: slack_pending"]
Step by step:
Webhookreceives the log batch from Vector. The request must carry the token header (credentialMSDA Webhook Token). The payload goes to two parallel branches.Raw: Build Document→Mongodb Insert: dns_raw_logsstores the payload as-is as an archive (it does not block the main flow).Split Out (per domain)splits the batch into one item per domain.Mongodb: Find document (dns_info)fetches existing classification results, andRedis Get: queuefetches the domains already queued.Filter Set as queue (Code - JS)decides which domains need AI analysis: those not indns_infoand not already queued.If - not on queue→Redis Set: queueadds them to the queue (6 hour TTL).Blacklist: Build Querybuilds a query containing only this batch's domains and their parent domains, plus the (few) group-level entries, thenMongodb: Find document (dns_blacklist)fetches the relevant entries (the whole collection is never loaded).- Three
Redis Keysnodes (dns_notification,slack_sent,slack_pending) fetch the existing dedup keys. Filter (Code - JS)processes each event: it matches the blocklist (only active, non-expired entries, including parent domains and group-level entries), addsgroup/typefromdns_info, readsblock_modeand computesenforced, marks detections and filters duplicates per domain + client. The result leaves through three branches:Mongodb Insert: ip_access_dnsstores every access, includingblock_modeandenforced.If - Detection DNSfor blocklisted domains:Send To MSDA Backendsends the notification to the backend (/api/v1/webhooks/n8n, headerX-MSDA-Token), andRedis Set: dns_notificationmarks it so it is not resent for 20 minutes.If - Notify Slack→Redis Set: slack_pendingplaces the alert in the delivery queue for lane 3.
flowchart TD
A["Cron 2 hours"] --> B["Redis: queue"] --> C["Extract queue keys"] --> D["Redis Get: queue value"] --> E["Aggregate domain"]
E --> F["AI: Plan Cache Query"] --> G["Find dns_info cache"] --> H["AI: Plan Domains"]
H --> I["Lookup: DoH A"] --> J["Merge A"] --> K["Lookup: DoH PTR"] --> L["Lookup: RDAP"] --> M["Lookup: Build"]
M --> N["AI: Build Batches (20 domains)"]
N --> O{"AI needed?"}
O -->|yes| P["AI Analyzer (Gemini)"] --> Q["Parse AI Result"]
O -->|no| Q
Q --> R["Mongodb Upsert: dns_info"] --> S["Redis del: queue"]
Cron (2 jam sekali)starts the lane;Redis: queuelists all queue keys,Extract queue keystidies them,Redis Get: queue valuefetches the values, andAggregatecollects them into one domain list.AI: Plan Cache Query→AI: Find dns_info (cache)→AI: Plan Domainsgroup domains by parent domain and skip those still inside the 30-day cache.Lookup: DoH A→Lookup: Merge A→Lookup: DoH PTR→Lookup: RDAP→Lookup: Buildfill in IP, PTR and ISP/organization from real sources (Cloudflare DoH and RDAP).AI: Build Batchessplits domains into batches of 20;AI: Perlu AI?decides whether the AI is still needed.AI Analyzercalls Gemini (gemini-3.5-flash-lite) with a delay between batches and 3 retries; the AI only fills in the classification (group, type, application).Parse AI Resultvalidates and merges the result,Mongodb Upsert: dns_infostores it, andRedis del: queueremoves the queue entry only after the save succeeded, so domains are not lost when the AI fails.
flowchart TD
A["Cron 1 minute"] --> B["Flush: Redis Keys pending"] --> C["Flush: Redis Keys sent"] --> D["Flush: Extract pending keys"]
D --> E["Flush: Redis Get pending value"] --> F["Group per Client"] --> G["Notify: Entry"]
G --> H["Notify: Read settings"] --> I["Notify: Prepare Messages"] --> J["Notify: Loop (one message per turn)"]
J --> K["Notify: Decide channel"]
K --> L{"Slack enabled?"}
L -->|yes| M["Notify: Send Slack"]
L --> N{"Telegram enabled?"}
M --> N
N -->|yes| O["Telegram Chunks"] --> P["Send Telegram"] --> Q["Wait 1s"]
N -->|no| R["Summarize"]
Q --> R
R --> J
J -->|done| S{"All succeeded?"}
S -->|yes| T["Route origin"]
S -->|no| U["Raise Failure"]
T --> V["Flush: Expand entries → Redis Set: slack_sent and Redis del pending"]
Lane 3 — blocklist alert delivery
- Every minute,
Cron Slack Flushcollects all pending alerts (Flush: Redis Keys pending,Flush: Redis Keys sent,Flush: Extract pending keys,Flush: Redis Get pending value). Group per Clientmerges the alerts of each client into one message (marking which domains are blocked at DNS and which are only monitored) and hands it toNotify: Entry.- When delivery succeeds,
Notify: Route originreturns the flow toFlush: Expand entries, which marks each entry as sent (Redis Set: slack_sent, 24 hour TTL) and removes it from the queue (Flush: Redis del pending). Marking happens after success so alerts are not lost when Slack/Telegram has problems.
Lane 4 — Notify (used by all other lanes)
Notify: Entryreceives{ origin, severity, title, text, data }from any lane.Notify: Read settingsreadsnotification_settings(_id: "global");Notify: Prepare Messagesbuilds the messages.Notify: Loopprocesses messages one by one.Notify: Decidepicks the channels from the settings, severity and quiet hours (criticalalways passes).Notify: IF Slack?→Notify: Send Slack, thenNotify: IF Telegram?→Notify: Telegram Chunks→Notify: Send Telegram→Notify: Wait 1s(a 1 second pause for the Telegram rate limit). A failure on one channel does not stop the other.Notify: Summarizerecords the result. After all messages are done,Notify: IF okchecks the outcome: on successNotify: Route originreturns the flow to the originating lane (blocklist, heartbeat down, heartbeat recovered, or anomaly); on failureNotify: Raise Failureraises an error.
Daily (00:05 WIB, or Manual Test for today's report):
Report: Date Range → Report: Aggregate ip_access_dns → Report: Build Daily Stats → Report: Ada data? (has data?)
├─ yes → Mongodb Upsert: dns_daily_stats
│ └→ Report: Ambil Riwayat (7 days) → Report: Compute Trends & Prompt
│ → Report: AI Analisis & Tren → Report: Parse AI & Build Message
│ → Mongodb Upsert: dns_ai_reports + Notify: Entry
└─ no → Notify: Entry (no-data notice)
The report covers yesterday; Manual Test produces a report for today (temporary). Statistics are stored first so they become history for the trend.
Weekly (Monday 00:10):
Weekly: Date Range → Mongodb: Find dns_daily_stats → Weekly: Compute (7 days vs previous 7 days) → Weekly: has data?
├─ yes → Weekly: AI Analisis → Weekly: Parse & Build → Mongodb Upsert: dns_ai_reports (weekly) + Notify: Entry
└─ no → Notify: Entry
Stored in dns_ai_reports with period: "weekly" and date: "YYYY-Www" so it never clashes with daily documents.
Heartbeat: Cron 5 minutes → Latest ip_access_dns → Redis Get: heartbeat_down → Decide Heartbeat → Switch action
├─ data late and no marker yet → Notify: Entry (critical) → [on success] Redis Set heartbeat_down
└─ data back and marker exists → Notify: Entry (recovery) → [on success] Redis del heartbeat_down
The default threshold is 15 minutes (MSDA_HEARTBEAT_MINUTES). The marker is set after the notification succeeded, so the alert is not lost when delivery fails.
Anomali: Cron 15 minutes → Latest ip_access_dns → Gate: new data? → Build Pipeline
→ Aggregate window + baseline (MongoDB) → Evaluate Rules → Redis Get: dedup
→ Dedup Filter + Summary → Notify: Entry
→ [on success] Route origin → Dedup: Build Keys → Redis Set: dedup
Rules evaluated (thresholds can be changed in the Evaluate Rules node):
| Rule | Description | Severity |
|---|---|---|
| AP spike | AP volume far above the 7-day baseline for the same hour | warning |
| Client spike | one client's volume far above its baseline | warning |
| High-entropy domain (DGA) | high name entropy and sufficient length | warning |
| Very long domain | domain or label above the length limit | warning |
| DoH/DoT bypass | access to public resolvers (for example dns.google, cloudflare-dns.com, dns.quad9.net) |
warning |
| New-domain spike | many never-seen domains in a short time | info |
Clients to ignore (for example the n8n server's own IP) are set through MSDA_ANOMALY_IGNORE_CLIENTS. The dedup key is set after the notification succeeded. The NXDOMAIN rule is not active yet because ip_access_dns does not store the response code.
Feed: Cron Daily 03:00 → Feed: Sources → Feed: Download → Feed: Normalize
→ Feed: Mongodb: Find locked (dns_blacklist) → Feed: Drop manual/locked entries
→ Feed: Mongodb Upsert: dns_blacklist → Feed: Summary → Notify: Entry
Feed: Sourcesholds the list of sources with a per-source switch, a limit of 2,000 entries per source per day, and a 7 day lifetime (renewed every day while the domain is still in the feed).- Manual entries and feed entries disabled by an admin are never overwritten. Feed entries are stored with
source: "feed"andexpires_at. - Feed entries are created in
alertmode. Switching an entry toenforceis a deliberate admin action in the dashboard. - Check each feed's license/usage terms before enabling it.
TG: Telegram Trigger → TG: Auth & Parse → TG: IF allowed → TG: Redis Rate Limit → TG: Route
→ (if button) TG: Telegram: Answer Callback → TG: Switch route
├─ /status → TG: Mongodb: status 1 hour → TG: Format Status ─────────────────→ TG: Telegram: Balas (reply)
├─ /top → TG: Mongodb: top 24 hours → TG: Format Top ────────────────────→ TG: Telegram: Balas
├─ /block → TG: Telegram: Konfirmasi Blokir (Yes/Cancel buttons)
│ └→ [Yes] TG: Block: Prepare doc → TG: Mongodb Upsert: blacklist (telegram)
│ → TG: Format Selesai → (offer MikroTik enforcement, if enabled) → Reply
├─ /unblock → TG: Unblock: Prepare doc → TG: Mongodb Update: unblock → TG: Format Selesai → Reply
└─ enforcement → TG: RouterOS: add static DNS → TG: Audit: Build → TG: Mongodb: audit → Reply (disabled)
Safeguards: only IDs in MSDA_TELEGRAM_ALLOWED_IDS are served; a limit of 10 commands per minute per user; domains are validated with a strict regex before touching MongoDB; /block always asks for button confirmation and records source: "telegram" and who requested it. MikroTik enforcement is only for IDs in MSDA_TELEGRAM_APPROVER_IDS. A domain blocked from Telegram is stored in alert mode; making it a DNS block is a separate admin action in the dashboard.
Error: Error Trigger → Error: Build Error Message → Notify: Entry (critical)
This workflow is set as the error workflow in its own settings, so a failure of any node is reported as a critical notification without sensitive data.
Lanes 1 to 4 keep their structure; the changes below add the AdGuard awareness.
Webhook → ... → Filter (Code - JS) ──┬→ Mongodb Insert: ip_access_dns (+ block_mode, enforced)
├→ If - Detection DNS → Send To MSDA Backend (+ enforced, block_mode)
└→ If - Notify Slack → Redis Set: slack_pending (+ enforced, block_mode)
AdGuard: Cron 5 minutes → AdGuard: Status backend → AdGuard: Redis Get: adguard_down → AdGuard: Decide → AdGuard: Switch action
├─ down → Notify: Entry → [on success] Route origin → AdGuard: Redis Set adguard_down
└─ recover → Notify: Entry → [on success] Route origin → AdGuard: Redis del adguard_down
Filter (Code - JS)readsblock_modefrom the matching blocklist entry (missing meansalert) and addsblock_modeandenforcedto every event.enforcedis true only forenforceentries whose rule is already synced (enforce_status = synced). The existingactionfield is untouched for compatibility (it means "matched the blocklist"); the dashboard usesenforcedfor the "Blocked" label. It also matches group-level entries (see the constantMATCH_GROUP_ENTRIES).Mongodb Insert: ip_access_dnsstores the two new fields (they are in itsfieldslist).Send To MSDA Backendsendsenforcedandblock_modeinsidedata(the backend accepts and stores them; old payloads still work).Redis Set: slack_pendingkeeps them so batching does not lose the information.- Alert text (
Group per Client) is in Indonesian like the rest of the messages: "DIBLOKIR (DNS)" when every domain is blocked, "terpantau (tidak diblokir)" when none is, and both counts for a mix; blocked domains carry "— diblokir". Message volume does not grow: dedup per domain + client and the 1 minute batching still apply. - AdGuard monitor lane only reads
GET /api/v1/webhooks/adguard-statusfrom the backend (headerX-MSDA-Token); n8n never calls AdGuard directly.AdGuard: Decidealerts once per outage:criticalforunreachableorprotection_off,warningforsync_error,sync_staleornever_synced, and sends a recovery message when the status is healthy again. The flag is set after the notification succeeded. When the feature is off (enabled: false) or the backend cannot be reached, the lane does nothing; backend downtime itself is already covered by the ingestion heartbeat and the Error Handler. Because the default MikroTik script is fail-closed and can be switched to fail-open, this lane is what tells you that blocking has stopped. Notify: Route originhas two new outputs,adguard_downandadguard_up.- Feed lane: feed entries are written in
alertmode, and the daily upsert only touches its own fields, so ablock_modeset by an admin on a feed entry is kept. Feed entries still expire after 7 days, which also removes them from the AdGuard rules. - Not implemented: totals of blocked attempts in the daily report (
dns_daily_stats), and a dedicatedblock_attemptnotification type with anotification_settings.events.block_attemptswitch. These alerts useblacklist_alert.
Credentials in n8n
| Credential | Type | Used by |
|---|---|---|
MSDA Webhook Token |
Header Auth (X-MSDA-Token) |
Webhook, Send To MSDA Backend, other backend calls |
MongoDB account |
MongoDB | all MongoDB nodes |
Redis account |
Redis | all Redis nodes |
Google Gemini(PaLM) Api account 2 |
Google PaLM/Gemini | AI Analyzer |
Google Gemini(PaLM) Api account 3 |
Google PaLM/Gemini | daily and weekly reports |
Slack account 2 |
Slack API | Notify: Send Slack |
MSDA Telegram |
Telegram API | Telegram delivery and commands |
MSDA RouterOS |
HTTP Basic Auth | MikroTik enforcement (optional, disabled) |
Environment variables (must be passed to the n8n container in docker-compose.yml):
| Variable | Purpose |
|---|---|
MSDA_TELEGRAM_CHAT_ID |
destination chat for Telegram notifications |
MSDA_TELEGRAM_ALLOWED_IDS |
user IDs allowed to give commands |
MSDA_TELEGRAM_APPROVER_IDS |
user IDs allowed to approve MikroTik enforcement |
MSDA_HEARTBEAT_MINUTES |
data delay threshold (default 15) |
MSDA_ANOMALY_IGNORE_CLIENTS |
client IPs ignored by anomaly detection |
MSDA_ENFORCE_ENABLED |
true enables MikroTik enforcement |
MSDA_ROUTEROS_URL |
RouterOS REST API address |
AdGuard configuration (optional, managed by the backend and Docker, not by n8n). Set these in infrastructure/docker/.env; the full guide is docs/adguard.md.
| Variable | Purpose |
|---|---|
MSDA_ENFORCEMENT_ENABLED |
false (default) disables the whole blocking feature |
MSDA_HOST_IP |
LAN IP of the Docker host, used to bind AdGuard's DNS port 53 (never 0.0.0.0 or a public IP) |
ADGUARD_URL, ADGUARD_USER, ADGUARD_PASSWORD |
AdGuard API access (backend only; password at least 12 characters) |
ADGUARD_BLOCKING_MODE |
answer returned for blocked domains: null_ip (recommended) or nxdomain |
ADGUARD_SYNC_INTERVAL |
periodic sync interval (for example 5m, minimum 30s) |
MSDA_NEVER_BLOCK |
domains that must never be blocked (also their subdomains and parents) |
MIKROTIK_IP, ADGUARD_UPSTREAM_DNS, ADGUARD_ALLOWED_CLIENTS, ADGUARD_UI_PORT |
used only by infrastructure/adguard/init.sh for the first-time AdGuard setup |
Intentionally disabled nodes: Anomali: Send Anomaly To Backend (waits for the backend to accept the anomaly_alert notification type), and TG: RouterOS: tambah DNS static and TG: Mongodb: audit (MikroTik enforcement). The workflow file is exported inactive; activate it once the credentials and environment variables are ready.
The old SATA_* names are no longer supported. Use the MSDA_* prefix in .env, the X-MSDA-Token header for webhooks, msda-* containers, and the msda MongoDB database (the name can be set with MONGODB_DB).
- Blocked-attempt totals in the daily report and a dedicated
block_attemptnotification type (section 2.11). Blocking public DoH/DoT resolvers stays a separate, optional step. - API security hardening, audit log, session management and 2FA, alert management, and trend charts in the dashboard.
- Scheduled backups and automated CI.
Originally developed as SATA-AI by ahnaffarras, ernestoyoofi, vulviantama, rafiramdhni1, continued as MSDA by ahnaffarras”