Skip to content

Commit 6376629

Browse files
committed
feat: dns audit, domains expiry, zone export, propagation check
New commands: - `infomaniak dns audit` — scan all domains for SPF/DMARC/DKIM/MX issues, multiple SPF records, CNAME-at-root, and low TTLs - `infomaniak domains` — list domains with expiry dates and warnings for domains expiring within N days - `infomaniak dns zone <domain>` — generate BIND-format zone file - `infomaniak dns propagation <domain>` — check propagation across Google, Cloudflare, Quad9, and OpenDNS resolvers 115 tests, all passing. Bump to v0.7.0.
1 parent 7a4eafe commit 6376629

12 files changed

Lines changed: 735 additions & 2 deletions

File tree

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ infomaniak dns sync example.com desired.json --dry-run # Sync (terraform-styl
7878
infomaniak dns clone source.com target.com # Clone between domains
7979
infomaniak dns search "76.76.21" # Search across all domains
8080
infomaniak dns backup # Backup all domains
81+
infomaniak dns audit # Audit all domains (SPF/DMARC/DKIM)
82+
infomaniak dns audit example.com # Audit single domain
83+
infomaniak dns zone example.com # Generate BIND zone file
84+
infomaniak dns propagation example.com # Check global DNS propagation
85+
infomaniak dns propagation example.com -n www -t CNAME # Check specific record
86+
```
87+
88+
### Domains
89+
90+
```bash
91+
infomaniak domains # List all domains with expiry dates
92+
infomaniak domains --warn 60 # Warn if expiring within 60 days
8193
```
8294

8395
### Account & Products
@@ -166,6 +178,51 @@ $ infomaniak dns sync example.com desired.json --dry-run
166178
167179
Dry run — no changes applied.
168180
181+
$ infomaniak dns audit
182+
183+
DNS Audit
184+
────────
185+
186+
Scanning 10 domain(s)...
187+
188+
! No SPF record found — Email spoofing protection
189+
→ legacy-site.com
190+
191+
! No DMARC record found — Email authentication policy
192+
→ legacy-site.com
193+
→ staging.org
194+
195+
Summary: 10 domains scanned
196+
8 clean
197+
2 with issues (3 total findings)
198+
199+
$ infomaniak dns propagation example.com
200+
201+
DNS Propagation: A example.com
202+
203+
Resolver IP Result
204+
────────── ────────────── ───────────
205+
Google 8.8.8.8 93.184.216.34
206+
Cloudflare 1.1.1.1 93.184.216.34
207+
Quad9 9.9.9.9 93.184.216.34
208+
OpenDNS 208.67.222.222 93.184.216.34
209+
210+
All resolvers agree. DNS is fully propagated.
211+
212+
$ infomaniak domains
213+
214+
Domains (3)
215+
216+
Domain Expires Days Left Status
217+
─────────────── ────────── ───────── ────────
218+
expiring.com 2026-04-01 17 expiring
219+
example.com 2027-01-15 306 active
220+
example.org 2027-06-20 462 active
221+
222+
Warning: 1 domain(s) expiring within 30 days:
223+
224+
! expiring.com — 17 days remaining
225+
169226
$ infomaniak status
170227
171228
Service Status — 5 products

infomaniak_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@
2525
infomaniak config show # Show configuration
2626
"""
2727

28-
__version__ = "0.6.0"
28+
__version__ = "0.7.0"
2929
API_BASE = "https://api.infomaniak.com"

infomaniak_cli/cli.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from infomaniak_cli import __version__
77
from infomaniak_cli.commands.account import cmd_account
8+
from infomaniak_cli.commands.audit import cmd_dns_audit
89
from infomaniak_cli.commands.config import cmd_config_show
910
from infomaniak_cli.commands.dns import (
1011
cmd_dns_add,
@@ -21,9 +22,12 @@
2122
cmd_dns_sync,
2223
cmd_dns_update,
2324
)
25+
from infomaniak_cli.commands.domains import cmd_domains
2426
from infomaniak_cli.commands.drive import cmd_drive_list
2527
from infomaniak_cli.commands.hosting import cmd_hosting_list
2628
from infomaniak_cli.commands.mail import cmd_mail_list, cmd_mail_mailboxes
29+
from infomaniak_cli.commands.propagation import cmd_dns_propagation
30+
from infomaniak_cli.commands.zone import cmd_dns_zone
2731
from infomaniak_cli.commands.products import cmd_products
2832
from infomaniak_cli.commands.setup import cmd_setup
2933
from infomaniak_cli.commands.status import cmd_status
@@ -142,6 +146,32 @@ def main():
142146
sp.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
143147
sp.set_defaults(func=cmd_dns_sync)
144148

149+
# dns audit
150+
sp = dns_sub.add_parser("audit", help="Audit DNS for misconfigurations (SPF, DMARC, DKIM, etc.)")
151+
sp.add_argument("domain", nargs="?", default=None, help="Domain to audit (default: all domains)")
152+
sp.add_argument("--json", action="store_true", help="Output as JSON")
153+
sp.set_defaults(func=cmd_dns_audit)
154+
155+
# dns zone
156+
sp = dns_sub.add_parser("zone", help="Generate BIND-format zone file")
157+
sp.add_argument("domain", help="Domain name")
158+
sp.add_argument("--output", "-o", help="Output file path (default: stdout)")
159+
sp.set_defaults(func=cmd_dns_zone)
160+
161+
# dns propagation
162+
sp = dns_sub.add_parser("propagation", help="Check DNS propagation across public resolvers")
163+
sp.add_argument("domain", help="Domain name")
164+
sp.add_argument("--name", "-n", default="@", help="Record name (default: @ for root)")
165+
sp.add_argument("--type", "-t", default="A", help="Record type (default: A)")
166+
sp.add_argument("--json", action="store_true", help="Output as JSON")
167+
sp.set_defaults(func=cmd_dns_propagation)
168+
169+
# ── domains ────────────────────────────────────────────────────────────
170+
sp_domains = subparsers.add_parser("domains", help="List domains with expiry tracking")
171+
sp_domains.add_argument("--warn", type=int, default=30, help="Warn if expiring within N days (default: 30)")
172+
sp_domains.add_argument("--json", action="store_true", help="Output as JSON")
173+
sp_domains.set_defaults(func=cmd_domains)
174+
145175
# ── config ─────────────────────────────────────────────────────────────
146176
config_parser = subparsers.add_parser("config", help="View configuration")
147177
config_sub = config_parser.add_subparsers(dest="command")

infomaniak_cli/commands/audit.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""DNS audit — scan domains for common misconfigurations."""
2+
3+
import sys
4+
5+
from infomaniak_cli.api import api_request
6+
from infomaniak_cli.config import get_account_id, get_token
7+
from infomaniak_cli.output import bold, cyan, dim, green, output_json, red, yellow
8+
9+
10+
def _has_record(records, rtype, source=None, substring=None):
11+
"""Check if records contain a specific type/source/substring match."""
12+
for r in records:
13+
if r.get("type") != rtype:
14+
continue
15+
if source is not None:
16+
rec_source = r.get("source", "@")
17+
if rec_source == ".":
18+
rec_source = "@"
19+
if rec_source != source:
20+
continue
21+
if substring is not None and substring.lower() not in r.get("target", "").lower():
22+
continue
23+
return True
24+
return False
25+
26+
27+
def _get_targets(records, rtype, source=None):
28+
"""Get all targets for a given type/source."""
29+
targets = []
30+
for r in records:
31+
if r.get("type") != rtype:
32+
continue
33+
if source is not None:
34+
rec_source = r.get("source", "@")
35+
if rec_source == ".":
36+
rec_source = "@"
37+
if rec_source != source:
38+
continue
39+
targets.append(r.get("target", ""))
40+
return targets
41+
42+
43+
def cmd_dns_audit(args):
44+
"""Audit DNS records across all domains for common issues."""
45+
token = get_token()
46+
account_id = get_account_id(token)
47+
48+
# Get all domains
49+
data = api_request("GET", f"/1/domain/account/{account_id}", token)
50+
domains = data.get("data", [])
51+
52+
if not domains:
53+
print(f" {dim('No domains found.')}")
54+
return
55+
56+
domain_filter = args.domain if hasattr(args, "domain") and args.domain else None
57+
if domain_filter:
58+
domains = [d for d in domains if d.get("customer_name") == domain_filter]
59+
if not domains:
60+
print(f" {red('Domain not found:')} {domain_filter}")
61+
sys.exit(1)
62+
63+
all_issues = []
64+
all_results = []
65+
total_checked = 0
66+
67+
print(f"\n {bold('DNS Audit')}")
68+
print(f" {dim('────────')}\n")
69+
print(f" Scanning {len(domains)} domain(s)...\n")
70+
71+
for d in domains:
72+
domain_name = d.get("customer_name", "")
73+
try:
74+
rdata = api_request("GET", f"/2/zones/{domain_name}/records", token)
75+
except SystemExit:
76+
continue
77+
78+
records = rdata.get("data", [])
79+
total_checked += 1
80+
domain_issues = []
81+
82+
# Check 1: SPF record
83+
spf_targets = _get_targets(records, "TXT", "@")
84+
has_spf = any("v=spf1" in t.lower() for t in spf_targets)
85+
if not has_spf:
86+
domain_issues.append(("missing_spf", "No SPF record found", "Email spoofing protection"))
87+
88+
# Check 2: DMARC record
89+
dmarc_sources = [r.get("source", "@") for r in records if r.get("type") == "TXT"]
90+
has_dmarc = _has_record(records, "TXT", "_dmarc")
91+
if not has_dmarc:
92+
domain_issues.append(("missing_dmarc", "No DMARC record found", "Email authentication policy"))
93+
94+
# Check 3: DKIM (check common selectors)
95+
has_dkim = False
96+
for r in records:
97+
source = r.get("source", "")
98+
if r.get("type") == "TXT" and "._domainkey" in source:
99+
has_dkim = True
100+
break
101+
if r.get("type") == "CNAME" and "._domainkey" in source:
102+
has_dkim = True
103+
break
104+
if not has_dkim:
105+
domain_issues.append(("missing_dkim", "No DKIM record found", "Email signing verification"))
106+
107+
# Check 4: MX record (if domain has mail hosting)
108+
has_mx = _has_record(records, "MX")
109+
a_targets = _get_targets(records, "A", "@")
110+
if not has_mx and a_targets:
111+
domain_issues.append(("missing_mx", "No MX record — mail won't be delivered", "Mail delivery"))
112+
113+
# Check 5: Multiple SPF records (invalid per RFC)
114+
spf_count = sum(1 for t in spf_targets if "v=spf1" in t.lower())
115+
if spf_count > 1:
116+
domain_issues.append(("multiple_spf", f"Multiple SPF records found ({spf_count})", "RFC violation — only one SPF allowed"))
117+
118+
# Check 6: Low TTL on root records
119+
for r in records:
120+
source = r.get("source", "@")
121+
if source in ("@", ".") and r.get("type") in ("A", "AAAA", "MX"):
122+
ttl = r.get("ttl", 3600)
123+
if ttl < 60:
124+
domain_issues.append(("low_ttl", f"Very low TTL ({ttl}s) on {r.get('type')} record", "May cause excessive DNS queries"))
125+
break
126+
127+
# Check 7: CNAME at root (invalid per RFC)
128+
has_root_cname = _has_record(records, "CNAME", "@")
129+
if has_root_cname:
130+
domain_issues.append(("root_cname", "CNAME record at root (@)", "RFC violation — may break MX/NS"))
131+
132+
result = {
133+
"domain": domain_name,
134+
"records": len(records),
135+
"issues": domain_issues,
136+
}
137+
all_results.append(result)
138+
139+
if domain_issues:
140+
all_issues.extend([(domain_name, *issue) for issue in domain_issues])
141+
142+
if getattr(args, "json", False):
143+
output_json(all_results)
144+
145+
# Summary
146+
clean = sum(1 for r in all_results if not r["issues"])
147+
with_issues = sum(1 for r in all_results if r["issues"])
148+
149+
if all_issues:
150+
# Group by issue type
151+
issue_types = {}
152+
for domain_name, code, desc, hint in all_issues:
153+
if code not in issue_types:
154+
issue_types[code] = []
155+
issue_types[code].append((domain_name, desc, hint))
156+
157+
for code, issues in sorted(issue_types.items()):
158+
hint = issues[0][2]
159+
desc = issues[0][1]
160+
print(f" {yellow('!')} {bold(desc)} {dim(f'— {hint}')}")
161+
for domain_name, _, _ in issues:
162+
print(f" {dim('→')} {domain_name}")
163+
print()
164+
165+
print(f" {bold('Summary')}: {total_checked} domains scanned")
166+
if clean:
167+
print(f" {green(str(clean))} clean")
168+
if with_issues:
169+
print(f" {yellow(str(with_issues))} with issues ({len(all_issues)} total findings)")
170+
if not all_issues:
171+
print(f" {green('All domains passed all checks.')}")
172+
print()

infomaniak_cli/commands/domains.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Domain overview with expiry tracking."""
2+
3+
from datetime import datetime, timezone
4+
5+
from infomaniak_cli.api import api_request_paginated
6+
from infomaniak_cli.config import get_account_id, get_token
7+
from infomaniak_cli.output import bold, cyan, dim, green, output_json, print_table, red, yellow
8+
9+
10+
def cmd_domains(args):
11+
"""List all domains with registration and expiry info."""
12+
token = get_token()
13+
account_id = get_account_id(token)
14+
15+
products = api_request_paginated(
16+
"/1/products", token,
17+
params={"account_id": account_id, "service_name": "domain"},
18+
)
19+
20+
if getattr(args, "json", False):
21+
output_json(products)
22+
23+
if not products:
24+
print(f" {dim('No domains found.')}")
25+
return
26+
27+
now = datetime.now(timezone.utc)
28+
warn_days = args.warn or 30
29+
30+
headers = ["Domain", "Expires", "Days Left", "Status"]
31+
rows = []
32+
expiring_soon = []
33+
34+
for p in products:
35+
name = p.get("customer_name", "?")
36+
expired_at = p.get("expired_at")
37+
38+
if expired_at:
39+
try:
40+
exp_date = datetime.fromtimestamp(int(expired_at), tz=timezone.utc)
41+
days_left = (exp_date - now).days
42+
exp_display = exp_date.strftime("%Y-%m-%d")
43+
44+
if days_left < 0:
45+
days_display = red(f"EXPIRED ({abs(days_left)}d ago)")
46+
status = red("expired")
47+
expiring_soon.append((name, days_left))
48+
elif days_left <= warn_days:
49+
days_display = yellow(str(days_left))
50+
status = yellow("expiring")
51+
expiring_soon.append((name, days_left))
52+
else:
53+
days_display = green(str(days_left))
54+
status = green("active")
55+
except (ValueError, TypeError):
56+
exp_display = dim("unknown")
57+
days_display = dim("?")
58+
status = dim("unknown")
59+
else:
60+
exp_display = dim("n/a")
61+
days_display = dim("n/a")
62+
status = green("active")
63+
64+
rows.append([name, exp_display, days_display, status])
65+
66+
# Sort by days left (soonest first)
67+
rows.sort(key=lambda r: r[1])
68+
69+
print(f"\n {bold(f'Domains ({len(rows)})')}\n")
70+
print_table(headers, rows)
71+
72+
if expiring_soon:
73+
print(f"\n {yellow(f'Warning: {len(expiring_soon)} domain(s) expiring within {warn_days} days:')}\n")
74+
for name, days in sorted(expiring_soon, key=lambda x: x[1]):
75+
if days < 0:
76+
print(f" {red('!')} {bold(name)} — expired {abs(days)} days ago")
77+
else:
78+
print(f" {yellow('!')} {bold(name)}{days} days remaining")
79+
80+
print()

0 commit comments

Comments
 (0)