@exla/rudder 3.9.1 → 3.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/skills/google-cloud/SKILL.md +70 -6
- package/assets/skills/google-cloud/scripts/gcp.mjs +9 -3
- package/assets/skills/namecheap/SKILL.md +130 -0
- package/assets/skills/namecheap/scripts/__pycache__/namecheapcpython-314.pyc +0 -0
- package/assets/skills/namecheap/scripts/namecheap +785 -0
- package/dist/agent-skills.d.ts +2 -0
- package/dist/agent-skills.js +18 -2
- package/dist/agent-skills.js.map +1 -1
- package/dist/attach-session.d.ts +8 -0
- package/dist/attach-session.js +28 -1
- package/dist/attach-session.js.map +1 -1
- package/dist/cloud.js +14 -2
- package/dist/cloud.js.map +1 -1
- package/dist/native/darwin-arm64/rudder-native +0 -0
- package/dist/native/darwin-x64/rudder-native +0 -0
- package/dist/native/linux-x64/rudder-native +0 -0
- package/dist/terminal-theme.d.ts +21 -0
- package/dist/terminal-theme.js +64 -0
- package/dist/terminal-theme.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""namecheap: the Namecheap account from the command line, safely, for agents.
|
|
3
|
+
|
|
4
|
+
Namecheap's setHosts REPLACES every host record on a domain, so record changes
|
|
5
|
+
never write blind: read the live records, merge, back the old set up, write,
|
|
6
|
+
re-read and check. Anything that costs money (register, renew) shows the price
|
|
7
|
+
and the balance and does nothing without --yes.
|
|
8
|
+
|
|
9
|
+
Account
|
|
10
|
+
namecheap setup <api-user> [<api-key>] [--username U] [--client-ip IP]
|
|
11
|
+
namecheap check credentials + IP whitelist + domain list
|
|
12
|
+
namecheap balance account balance
|
|
13
|
+
namecheap domains [--expiring|--expired] [--search TEXT]
|
|
14
|
+
namecheap info <domain> status, expiry, auto-renew, privacy, nameservers
|
|
15
|
+
namecheap contacts <domain> registrant/admin/tech/billing contacts
|
|
16
|
+
|
|
17
|
+
Buying (prices first; --yes to spend)
|
|
18
|
+
namecheap available <domain> [...] availability + premium price
|
|
19
|
+
namecheap price <tld> [--action register|renew|transfer]
|
|
20
|
+
namecheap register <domain> --contacts-from <owned domain> [--years N] [--yes]
|
|
21
|
+
namecheap renew <domain> [--years N] [--yes]
|
|
22
|
+
namecheap transfers incoming transfers and their status
|
|
23
|
+
|
|
24
|
+
Registrar settings
|
|
25
|
+
namecheap nameservers <domain> [--set NS1 NS2 ...|--default]
|
|
26
|
+
namecheap lock <domain> [--on|--off] registrar (transfer) lock
|
|
27
|
+
namecheap privacy [<domain>] [--on [--email E]|--off] free domain privacy
|
|
28
|
+
|
|
29
|
+
DNS (Namecheap BasicDNS)
|
|
30
|
+
namecheap list <domain> host records and email mode
|
|
31
|
+
namecheap set <domain> --add "A @ 76.76.21.21" --remove "CNAME www" [--replace] [--dry-run]
|
|
32
|
+
namecheap vercel <domain> [--sub NAME ...] [--dry-run]
|
|
33
|
+
apex + www (or subdomains) to Vercel, parking removed
|
|
34
|
+
namecheap forward <domain> [--add hello=you@example.com] [--remove hello] [--dry-run]
|
|
35
|
+
namecheap lookup <name> [TYPE] DNS-over-HTTPS lookup (no dig needed)
|
|
36
|
+
namecheap restore <backup.json> [--dry-run]
|
|
37
|
+
|
|
38
|
+
Credentials come from the environment or ~/.config/namecheap/credentials.env:
|
|
39
|
+
NAMECHEAP_API_USER, NAMECHEAP_API_KEY, NAMECHEAP_USERNAME (defaults to the
|
|
40
|
+
API user), NAMECHEAP_CLIENT_IP (defaults to this machine's public IPv4),
|
|
41
|
+
NAMECHEAP_SANDBOX=1 for the sandbox API.
|
|
42
|
+
"""
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import shlex
|
|
47
|
+
import sys
|
|
48
|
+
import urllib.error
|
|
49
|
+
import urllib.parse
|
|
50
|
+
import urllib.request
|
|
51
|
+
import xml.etree.ElementTree as ET
|
|
52
|
+
from datetime import datetime
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
API = 'https://api.namecheap.com/xml.response'
|
|
56
|
+
SANDBOX_API = 'https://api.sandbox.namecheap.com/xml.response'
|
|
57
|
+
CONFIG_DIR = Path(os.environ.get('NAMECHEAP_CONFIG_DIR', Path.home() / '.config' / 'namecheap'))
|
|
58
|
+
CRED_FILE = CONFIG_DIR / 'credentials.env'
|
|
59
|
+
BACKUP_DIR = CONFIG_DIR / 'backups'
|
|
60
|
+
NS = '{http://api.namecheap.com/xml.response}'
|
|
61
|
+
TWO_LEVEL_TLDS = {
|
|
62
|
+
'co.uk', 'org.uk', 'me.uk', 'ltd.uk', 'plc.uk', 'net.uk', 'com.au', 'net.au', 'org.au',
|
|
63
|
+
'co.nz', 'net.nz', 'org.nz', 'co.jp', 'com.br', 'com.mx', 'co.in', 'co.za', 'com.sg', 'com.tr',
|
|
64
|
+
}
|
|
65
|
+
# Types this tool can read back and write again without losing fields.
|
|
66
|
+
ROUND_TRIP_TYPES = {'A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'MXE', 'NS', 'TXT', 'URL', 'URL301', 'FRAME'}
|
|
67
|
+
REDIRECT_TYPES = {'URL', 'URL301', 'FRAME'}
|
|
68
|
+
PARKING_TARGETS = ('parkingpage.namecheap.com',)
|
|
69
|
+
VERCEL_A = '76.76.21.21'
|
|
70
|
+
VERCEL_CNAME = 'cname.vercel-dns.com.'
|
|
71
|
+
DEFAULT_TTL = '1800'
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ApiError(Exception):
|
|
75
|
+
def __init__(self, errors, client_ip):
|
|
76
|
+
self.errors = errors
|
|
77
|
+
hints = []
|
|
78
|
+
numbers = {number for number, _ in errors}
|
|
79
|
+
text = ' '.join(message for _, message in errors).lower()
|
|
80
|
+
if '1011150' in numbers or ('ip' in text and ('whitelist' in text or 'invalid request ip' in text)):
|
|
81
|
+
hints.append(
|
|
82
|
+
f'This machine\'s IP {client_ip} is not whitelisted. Namecheap dashboard: Profile > Tools > '
|
|
83
|
+
'Namecheap API Access > Whitelisted IPs > add it.')
|
|
84
|
+
if numbers & {'1011102', '1011104', '1010102', '1010104'} or 'api key' in text or 'apiuser' in text:
|
|
85
|
+
hints.append('API user or key rejected. Re-run `namecheap setup` with the key from Profile > Tools > Namecheap API Access.')
|
|
86
|
+
if 'not associated' in text or 'domain not found' in text or numbers & {'2019166', '2016166'}:
|
|
87
|
+
hints.append('That domain is not in this Namecheap account.')
|
|
88
|
+
detail = '; '.join(f'{number}: {message}' for number, message in errors)
|
|
89
|
+
super().__init__(detail + ''.join(f'\n -> {hint}' for hint in hints))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------- credentials and transport ----------
|
|
93
|
+
|
|
94
|
+
def read_cred_file():
|
|
95
|
+
values = {}
|
|
96
|
+
if CRED_FILE.exists():
|
|
97
|
+
for line in CRED_FILE.read_text().splitlines():
|
|
98
|
+
line = line.strip()
|
|
99
|
+
if line and not line.startswith('#') and '=' in line:
|
|
100
|
+
key, value = line.split('=', 1)
|
|
101
|
+
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
102
|
+
return values
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def public_ip():
|
|
106
|
+
for url in ('https://api.ipify.org', 'https://ifconfig.me/ip', 'https://ipv4.icanhazip.com'):
|
|
107
|
+
try:
|
|
108
|
+
with urllib.request.urlopen(url, timeout=8) as response:
|
|
109
|
+
ip = response.read().decode().strip()
|
|
110
|
+
if ip.count('.') == 3:
|
|
111
|
+
return ip
|
|
112
|
+
except (urllib.error.URLError, OSError):
|
|
113
|
+
continue
|
|
114
|
+
sys.exit('Could not find this machine\'s public IPv4. Set NAMECHEAP_CLIENT_IP.')
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_creds():
|
|
118
|
+
stored = read_cred_file()
|
|
119
|
+
get = lambda key: os.environ.get(key) or stored.get(key)
|
|
120
|
+
user, key = get('NAMECHEAP_API_USER'), get('NAMECHEAP_API_KEY')
|
|
121
|
+
if not user or not key:
|
|
122
|
+
sys.exit(
|
|
123
|
+
'NO_NAMECHEAP_CREDENTIALS: no API user/key in the environment or ' + str(CRED_FILE) + '.\n'
|
|
124
|
+
'One-time setup (the user does this once, then never again):\n'
|
|
125
|
+
' 1. Namecheap dashboard > Profile > Tools > Namecheap API Access > turn ON\n'
|
|
126
|
+
' (Namecheap requires $50 balance, $50 spent in 2 years, or 20 domains).\n'
|
|
127
|
+
f' 2. Whitelisted IPs > add {public_ip()} (this machine).\n'
|
|
128
|
+
' 3. namecheap setup <namecheap-username> (prompts for the key)')
|
|
129
|
+
return {
|
|
130
|
+
'ApiUser': user,
|
|
131
|
+
'ApiKey': key,
|
|
132
|
+
'UserName': get('NAMECHEAP_USERNAME') or user,
|
|
133
|
+
'ClientIp': get('NAMECHEAP_CLIENT_IP') or public_ip(),
|
|
134
|
+
'sandbox': get('NAMECHEAP_SANDBOX') == '1',
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def call(creds, command, **params):
|
|
139
|
+
data = {name: creds[name] for name in ('ApiUser', 'ApiKey', 'UserName', 'ClientIp')}
|
|
140
|
+
data['Command'] = command
|
|
141
|
+
data.update({name: str(value) for name, value in params.items()})
|
|
142
|
+
request = urllib.request.Request(
|
|
143
|
+
SANDBOX_API if creds['sandbox'] else API,
|
|
144
|
+
data=urllib.parse.urlencode(data).encode(),
|
|
145
|
+
method='POST',
|
|
146
|
+
)
|
|
147
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
148
|
+
root = ET.fromstring(response.read())
|
|
149
|
+
if root.get('Status') != 'OK':
|
|
150
|
+
errors = [(error.get('Number', '?'), (error.text or '').strip()) for error in root.iter(NS + 'Error')]
|
|
151
|
+
raise ApiError(errors or [('?', 'unknown error')], creds['ClientIp'])
|
|
152
|
+
return root.find(NS + 'CommandResponse')
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------- domain records ----------
|
|
156
|
+
|
|
157
|
+
def split_domain(domain):
|
|
158
|
+
labels = domain.lower().strip().strip('.').split('.')
|
|
159
|
+
if len(labels) >= 3 and '.'.join(labels[-2:]) in TWO_LEVEL_TLDS:
|
|
160
|
+
sld, tld, extra = labels[-3], '.'.join(labels[-2:]), labels[:-3]
|
|
161
|
+
elif len(labels) >= 2:
|
|
162
|
+
sld, tld, extra = labels[-2], labels[-1], labels[:-2]
|
|
163
|
+
else:
|
|
164
|
+
sys.exit(f'Not a domain: {domain}')
|
|
165
|
+
if extra:
|
|
166
|
+
sys.exit(f'Pass the registered domain ({sld}.{tld}), not a subdomain; use the host field for "{".".join(extra)}".')
|
|
167
|
+
return sld, tld
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def norm(record_type, value):
|
|
171
|
+
value = (value or '').strip()
|
|
172
|
+
return value if record_type == 'TXT' else value.rstrip('.').lower()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def same(a, b):
|
|
176
|
+
return (a['type'] == b['type'] and a['host'].lower() == b['host'].lower()
|
|
177
|
+
and norm(a['type'], a['address']) == norm(b['type'], b['address']))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def fmt(record):
|
|
181
|
+
extra = f" pref={record['mxpref']}" if record['type'] in ('MX', 'MXE') else ''
|
|
182
|
+
return f"{record['type']:<6} {record['host']:<14} {record['address']} ttl={record['ttl']}{extra}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_hosts(creds, domain):
|
|
186
|
+
sld, tld = split_domain(domain)
|
|
187
|
+
result = call(creds, 'namecheap.domains.dns.getHosts', SLD=sld, TLD=tld).find(NS + 'DomainDNSGetHostsResult')
|
|
188
|
+
records = [{
|
|
189
|
+
'type': host.get('Type', '').upper(),
|
|
190
|
+
'host': host.get('Name', ''),
|
|
191
|
+
'address': host.get('Address', ''),
|
|
192
|
+
'ttl': host.get('TTL') or DEFAULT_TTL,
|
|
193
|
+
'mxpref': host.get('MXPref') or '10',
|
|
194
|
+
} for host in result.iter(NS + 'host')]
|
|
195
|
+
return {
|
|
196
|
+
'domain': domain,
|
|
197
|
+
'records': records,
|
|
198
|
+
'email_type': (result.get('EmailType') or '').upper(),
|
|
199
|
+
'using_namecheap_dns': (result.get('IsUsingOurDNS') or '').lower() == 'true',
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def set_hosts(creds, domain, records, email_type):
|
|
204
|
+
sld, tld = split_domain(domain)
|
|
205
|
+
params = {'SLD': sld, 'TLD': tld}
|
|
206
|
+
for index, record in enumerate(records, 1):
|
|
207
|
+
params[f'HostName{index}'] = record['host']
|
|
208
|
+
params[f'RecordType{index}'] = record['type']
|
|
209
|
+
params[f'Address{index}'] = record['address']
|
|
210
|
+
params[f'TTL{index}'] = record['ttl'] or DEFAULT_TTL
|
|
211
|
+
if record['type'] in ('MX', 'MXE'):
|
|
212
|
+
params[f'MXPref{index}'] = record['mxpref'] or '10'
|
|
213
|
+
if email_type and email_type != 'NONE':
|
|
214
|
+
params['EmailType'] = email_type
|
|
215
|
+
result = call(creds, 'namecheap.domains.dns.setHosts', **params).find(NS + 'DomainDNSSetHostsResult')
|
|
216
|
+
if result is None or (result.get('IsSuccess') or '').lower() != 'true':
|
|
217
|
+
sys.exit('setHosts did not report success; nothing verified. Check the domain in the dashboard.')
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def parse_spec(spec, need_value):
|
|
221
|
+
parts = shlex.split(spec)
|
|
222
|
+
if len(parts) < (3 if need_value else 2):
|
|
223
|
+
sys.exit(f'Bad record "{spec}". Use "TYPE HOST VALUE [TTL] [MXPREF]", e.g. "A @ 76.76.21.21".')
|
|
224
|
+
record = {'type': parts[0].upper(), 'host': parts[1], 'address': parts[2] if len(parts) > 2 else None,
|
|
225
|
+
'ttl': parts[3] if len(parts) > 3 else DEFAULT_TTL, 'mxpref': parts[4] if len(parts) > 4 else '10'}
|
|
226
|
+
if need_value and record['type'] not in ROUND_TRIP_TYPES:
|
|
227
|
+
sys.exit(f'Record type {record["type"]} is not supported by this tool.')
|
|
228
|
+
return record
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def conflicts(existing, new):
|
|
232
|
+
"""Records that cannot coexist with `new` at the same host."""
|
|
233
|
+
host = new['host'].lower()
|
|
234
|
+
out = []
|
|
235
|
+
for record in existing:
|
|
236
|
+
if record['host'].lower() != host or same(record, new):
|
|
237
|
+
continue
|
|
238
|
+
if new['type'] == 'CNAME' or record['type'] == 'CNAME':
|
|
239
|
+
out.append(record) # a CNAME owns its name outright
|
|
240
|
+
elif new['type'] in ('A', 'AAAA', 'ALIAS') and record['type'] in REDIRECT_TYPES | {'ALIAS'}:
|
|
241
|
+
out.append(record) # a redirect or ALIAS answers for the same name
|
|
242
|
+
elif new['type'] in REDIRECT_TYPES and record['type'] in {'A', 'AAAA', 'ALIAS'} | REDIRECT_TYPES:
|
|
243
|
+
out.append(record)
|
|
244
|
+
elif new['type'] == record['type'] and new['type'] in ('A', 'AAAA'):
|
|
245
|
+
out.append(record) # a second A would round-robin traffic to the old target
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def plan_change(current, adds, removes, replace):
|
|
250
|
+
records = [dict(record) for record in current]
|
|
251
|
+
removed, added = [], []
|
|
252
|
+
for spec in removes:
|
|
253
|
+
for record in list(records):
|
|
254
|
+
if (record['type'] == spec['type'] and record['host'].lower() == spec['host'].lower()
|
|
255
|
+
and (spec['address'] is None or norm(record['type'], record['address']) == norm(spec['type'], spec['address']))):
|
|
256
|
+
records.remove(record)
|
|
257
|
+
removed.append(record)
|
|
258
|
+
for new in adds:
|
|
259
|
+
if any(same(record, new) for record in records):
|
|
260
|
+
continue
|
|
261
|
+
clash = conflicts(records, new)
|
|
262
|
+
if clash and not replace:
|
|
263
|
+
sys.exit('Refusing: ' + fmt(new) + ' conflicts with existing record(s):\n '
|
|
264
|
+
+ '\n '.join(fmt(record) for record in clash)
|
|
265
|
+
+ '\nRe-run with --replace to swap them, or --remove them explicitly.')
|
|
266
|
+
for record in clash:
|
|
267
|
+
records.remove(record)
|
|
268
|
+
removed.append(record)
|
|
269
|
+
records.append(new)
|
|
270
|
+
added.append(new)
|
|
271
|
+
return records, removed, added
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def backup(state):
|
|
275
|
+
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
os.chmod(CONFIG_DIR, 0o700)
|
|
277
|
+
# Microseconds keep two writes in the same second from overwriting each other's backup.
|
|
278
|
+
path = BACKUP_DIR / f"{state['domain']}-{datetime.now().strftime('%Y%m%dT%H%M%S.%f')}.json"
|
|
279
|
+
path.write_text(json.dumps(state, indent=2))
|
|
280
|
+
return path
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def apply(creds, domain, state, records, removed, added, dry_run, email_type=None):
|
|
284
|
+
unknown = [record for record in state['records'] if record['type'] not in ROUND_TRIP_TYPES]
|
|
285
|
+
if unknown:
|
|
286
|
+
sys.exit('Refusing to rewrite: the domain has record types this tool cannot round-trip:\n '
|
|
287
|
+
+ '\n '.join(fmt(record) for record in unknown) + '\nChange them in the dashboard instead.')
|
|
288
|
+
if not state['using_namecheap_dns']:
|
|
289
|
+
sys.exit(f'{domain} is not on Namecheap DNS (custom nameservers). Change records at that DNS host instead.')
|
|
290
|
+
for record in removed:
|
|
291
|
+
print('- ' + fmt(record))
|
|
292
|
+
for record in added:
|
|
293
|
+
print('+ ' + fmt(record))
|
|
294
|
+
target_email = email_type or state['email_type']
|
|
295
|
+
if target_email != state['email_type']:
|
|
296
|
+
print(f"~ email mode {state['email_type'] or 'NONE'} -> {target_email}")
|
|
297
|
+
if not removed and not added and target_email == state['email_type']:
|
|
298
|
+
print('No change needed; the records are already in place.')
|
|
299
|
+
return
|
|
300
|
+
if dry_run:
|
|
301
|
+
print('(dry run: nothing written)')
|
|
302
|
+
return
|
|
303
|
+
saved = backup(state)
|
|
304
|
+
print(f'Backed up previous records to {saved}')
|
|
305
|
+
set_hosts(creds, domain, records, target_email)
|
|
306
|
+
after = get_hosts(creds, domain)
|
|
307
|
+
missing = [record for record in added if not any(same(record, live) for live in after['records'])]
|
|
308
|
+
lingering = [record for record in removed if any(same(record, live) for live in after['records'])]
|
|
309
|
+
if missing or lingering:
|
|
310
|
+
sys.exit('Verification failed after write. Missing: ' + ', '.join(map(fmt, missing))
|
|
311
|
+
+ ' Still present: ' + ', '.join(map(fmt, lingering)) + f'\nRestore with: namecheap restore {saved}')
|
|
312
|
+
print(f'Verified: {len(after["records"])} records live on Namecheap. Public DNS catches up within the TTL:')
|
|
313
|
+
print(f' namecheap lookup {domain} A')
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------- commands ----------
|
|
317
|
+
|
|
318
|
+
def cmd_setup(args):
|
|
319
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
os.chmod(CONFIG_DIR, 0o700)
|
|
321
|
+
api_key = args.api_key
|
|
322
|
+
if not api_key:
|
|
323
|
+
import getpass
|
|
324
|
+
# Prompted, not an argument: a key on the command line lands in shell
|
|
325
|
+
# history and the process list.
|
|
326
|
+
api_key = getpass.getpass('Namecheap API key: ').strip()
|
|
327
|
+
if not api_key:
|
|
328
|
+
sys.exit('No API key given.')
|
|
329
|
+
lines = [f'NAMECHEAP_API_USER={args.api_user}', f'NAMECHEAP_API_KEY={api_key}']
|
|
330
|
+
if args.username:
|
|
331
|
+
lines.append(f'NAMECHEAP_USERNAME={args.username}')
|
|
332
|
+
if args.client_ip:
|
|
333
|
+
lines.append(f'NAMECHEAP_CLIENT_IP={args.client_ip}')
|
|
334
|
+
fd = os.open(CRED_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
335
|
+
with os.fdopen(fd, 'w') as handle:
|
|
336
|
+
handle.write('\n'.join(lines) + '\n')
|
|
337
|
+
os.chmod(CRED_FILE, 0o600)
|
|
338
|
+
print(f'Saved credentials to {CRED_FILE} (mode 600).')
|
|
339
|
+
cmd_check(args)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def cmd_check(_args):
|
|
343
|
+
creds = load_creds()
|
|
344
|
+
result = call(creds, 'namecheap.domains.getList', PageSize=100).find(NS + 'DomainGetListResult')
|
|
345
|
+
domains = [(domain.get('Name'), domain.get('IsOurDNS'), domain.get('Expires')) for domain in result.iter(NS + 'Domain')]
|
|
346
|
+
print(f"OK: API user {creds['ApiUser']} from IP {creds['ClientIp']}. {len(domains)} domain(s):")
|
|
347
|
+
for name, ours, expires in domains:
|
|
348
|
+
print(f" {name:<32} {'Namecheap DNS' if (ours or '').lower() == 'true' else 'custom nameservers':<20} expires {expires}")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def cmd_list(args):
|
|
352
|
+
state = get_hosts(load_creds(), args.domain)
|
|
353
|
+
print(f"{args.domain}: {'Namecheap DNS' if state['using_namecheap_dns'] else 'custom nameservers'}, "
|
|
354
|
+
f"email mode {state['email_type'] or 'NONE'}")
|
|
355
|
+
for record in state['records']:
|
|
356
|
+
print(' ' + fmt(record))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def cmd_set(args):
|
|
360
|
+
creds = load_creds()
|
|
361
|
+
state = get_hosts(creds, args.domain)
|
|
362
|
+
adds = [parse_spec(spec, True) for spec in args.add]
|
|
363
|
+
removes = [parse_spec(spec, False) for spec in args.remove]
|
|
364
|
+
records, removed, added = plan_change(state['records'], adds, removes, args.replace)
|
|
365
|
+
apply(creds, args.domain, state, records, removed, added, args.dry_run)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def cmd_vercel(args):
|
|
369
|
+
creds = load_creds()
|
|
370
|
+
state = get_hosts(creds, args.domain)
|
|
371
|
+
removes = []
|
|
372
|
+
for record in state['records']:
|
|
373
|
+
if norm('CNAME', record['address']).endswith(PARKING_TARGETS) or (
|
|
374
|
+
record['type'] in REDIRECT_TYPES and record['host'] in ('@', 'www')):
|
|
375
|
+
removes.append(dict(record)) # Namecheap's default parking page and redirects
|
|
376
|
+
names = args.sub or ['@', 'www']
|
|
377
|
+
adds = []
|
|
378
|
+
for name in names:
|
|
379
|
+
if name == '@':
|
|
380
|
+
adds.append({'type': 'A', 'host': '@', 'address': VERCEL_A, 'ttl': DEFAULT_TTL, 'mxpref': '10'})
|
|
381
|
+
else:
|
|
382
|
+
adds.append({'type': 'CNAME', 'host': name, 'address': VERCEL_CNAME, 'ttl': DEFAULT_TTL, 'mxpref': '10'})
|
|
383
|
+
records, removed, added = plan_change(state['records'], adds, removes, replace=True)
|
|
384
|
+
apply(creds, args.domain, state, records, removed, added, args.dry_run)
|
|
385
|
+
if not args.dry_run:
|
|
386
|
+
print('Next: `vercel domains add ' + (args.domain if names == ['@', 'www'] else f'{names[0]}.{args.domain}')
|
|
387
|
+
+ ' <project>` if not attached yet, then `vercel domains inspect` to confirm.')
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def cmd_forward(args):
|
|
391
|
+
creds = load_creds()
|
|
392
|
+
domain = args.domain.lower()
|
|
393
|
+
result = call(creds, 'namecheap.domains.dns.getEmailForwarding', DomainName=domain).find(
|
|
394
|
+
NS + 'DomainDNSGetEmailForwardingResult')
|
|
395
|
+
forwards = [(forward.get('mailbox'), (forward.text or '').strip()) for forward in result.iter(NS + 'Forward')]
|
|
396
|
+
if not args.add and not args.remove:
|
|
397
|
+
print(f'{domain} email forwards:' if forwards else f'{domain}: no email forwards')
|
|
398
|
+
for mailbox, target in forwards:
|
|
399
|
+
print(f' {mailbox}@{domain} -> {target}')
|
|
400
|
+
return
|
|
401
|
+
wanted = [(mailbox, target) for mailbox, target in forwards if mailbox not in set(args.remove)]
|
|
402
|
+
for spec in args.add:
|
|
403
|
+
if '=' not in spec:
|
|
404
|
+
sys.exit(f'Bad forward "{spec}". Use mailbox=destination, e.g. hello=you@example.com')
|
|
405
|
+
mailbox, target = (part.strip() for part in spec.split('=', 1))
|
|
406
|
+
wanted = [(m, t) for m, t in wanted if m != mailbox] + [(mailbox, target)]
|
|
407
|
+
state = get_hosts(creds, domain)
|
|
408
|
+
if state['email_type'] != 'FWD':
|
|
409
|
+
has_mx = any(record['type'] in ('MX', 'MXE') for record in state['records'])
|
|
410
|
+
if has_mx and not args.force:
|
|
411
|
+
sys.exit(f"{domain} receives mail through MX records (mode {state['email_type']}). Switching to "
|
|
412
|
+
'forwarding would cut that mail off. Re-run with --force only if that mail setup is unused.')
|
|
413
|
+
print(f"Switching {domain} email mode {state['email_type'] or 'NONE'} -> FWD")
|
|
414
|
+
records = [record for record in state['records'] if record['type'] not in ('MX', 'MXE')]
|
|
415
|
+
removed = [record for record in state['records'] if record['type'] in ('MX', 'MXE')]
|
|
416
|
+
apply(creds, domain, state, records, removed, [], args.dry_run, email_type='FWD')
|
|
417
|
+
for mailbox, target in wanted:
|
|
418
|
+
print(f' {mailbox}@{domain} -> {target}')
|
|
419
|
+
if args.dry_run:
|
|
420
|
+
print('(dry run: forwards not written)')
|
|
421
|
+
return
|
|
422
|
+
params = {'DomainName': domain}
|
|
423
|
+
for index, (mailbox, target) in enumerate(wanted, 1):
|
|
424
|
+
params[f'MailBox{index}'] = mailbox
|
|
425
|
+
params[f'ForwardTo{index}'] = target
|
|
426
|
+
call(creds, 'namecheap.domains.dns.setEmailForwarding', **params)
|
|
427
|
+
print('Forwards saved.')
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def text(node, tag):
|
|
431
|
+
found = node.find(NS + tag) if node is not None else None
|
|
432
|
+
return (found.text or '').strip() if found is not None else ''
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def cmd_balance(_args):
|
|
436
|
+
result = call(load_creds(), 'namecheap.users.getBalances').find(NS + 'UserGetBalancesResult')
|
|
437
|
+
print(f"Available {result.get('AvailableBalance')} {result.get('Currency')} "
|
|
438
|
+
f"(account {result.get('AccountBalance')}, earned {result.get('EarnedAmount')}, "
|
|
439
|
+
f"funds needed for auto-renew {result.get('FundsRequiredForAutoRenew')})")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def all_domains(creds, list_type='ALL', search=None):
|
|
443
|
+
page, out = 1, []
|
|
444
|
+
while True:
|
|
445
|
+
params = {'PageSize': 100, 'Page': page, 'ListType': list_type}
|
|
446
|
+
if search:
|
|
447
|
+
params['SearchTerm'] = search
|
|
448
|
+
response = call(creds, 'namecheap.domains.getList', **params)
|
|
449
|
+
batch = list(response.find(NS + 'DomainGetListResult').iter(NS + 'Domain'))
|
|
450
|
+
out.extend(batch)
|
|
451
|
+
paging = response.find(NS + 'Paging')
|
|
452
|
+
total = int(text(paging, 'TotalItems') or len(out))
|
|
453
|
+
if not batch or len(out) >= total:
|
|
454
|
+
return out
|
|
455
|
+
page += 1
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def cmd_domains(args):
|
|
459
|
+
list_type = 'EXPIRING' if args.expiring else 'EXPIRED' if args.expired else 'ALL'
|
|
460
|
+
domains = all_domains(load_creds(), list_type, args.search)
|
|
461
|
+
print(f'{len(domains)} domain(s):')
|
|
462
|
+
for domain in domains:
|
|
463
|
+
flags = [label for label, attr in (('auto-renew', 'AutoRenew'), ('locked', 'IsLocked'), ('privacy', 'WhoisGuard'))
|
|
464
|
+
if (domain.get(attr) or '').lower() in ('true', 'enabled')]
|
|
465
|
+
dns = 'Namecheap DNS' if (domain.get('IsOurDNS') or '').lower() == 'true' else 'custom NS'
|
|
466
|
+
print(f" {domain.get('Name'):<32} expires {domain.get('Expires'):<11} {dns:<14} {' '.join(flags)}")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def cmd_info(args):
|
|
470
|
+
creds = load_creds()
|
|
471
|
+
result = call(creds, 'namecheap.domains.getInfo', DomainName=args.domain).find(NS + 'DomainGetInfoResult')
|
|
472
|
+
details = result.find(NS + 'DomainDetails')
|
|
473
|
+
privacy = result.find(NS + 'Whoisguard')
|
|
474
|
+
dns = result.find(NS + 'DnsDetails')
|
|
475
|
+
print(f"{result.get('DomainName')}: status {result.get('Status')}, owner {result.get('OwnerName')}, "
|
|
476
|
+
f"premium {result.get('IsPremium')}")
|
|
477
|
+
print(f" created {text(details, 'CreatedDate')}, expires {text(details, 'ExpiredDate')}")
|
|
478
|
+
print(f" privacy {privacy.get('Enabled') if privacy is not None else '?'}"
|
|
479
|
+
f"{' until ' + text(privacy, 'ExpiredDate') if privacy is not None and text(privacy, 'ExpiredDate') else ''}")
|
|
480
|
+
if dns is not None:
|
|
481
|
+
servers = [(server.text or '').strip() for server in dns.iter(NS + 'Nameserver')]
|
|
482
|
+
print(f" DNS {dns.get('ProviderType')}: {', '.join(servers) or '(none listed)'}")
|
|
483
|
+
lock = call(creds, 'namecheap.domains.getRegistrarLock', DomainName=args.domain).find(NS + 'DomainGetRegistrarLockResult')
|
|
484
|
+
print(f" registrar lock {lock.get('RegistrarLockStatus') if lock is not None else '?'}")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
CONTACT_ROLES = ('Registrant', 'Tech', 'Admin', 'AuxBilling')
|
|
488
|
+
CONTACT_FIELDS = ('OrganizationName', 'JobTitle', 'FirstName', 'LastName', 'Address1', 'Address2', 'City',
|
|
489
|
+
'StateProvince', 'StateProvinceChoice', 'PostalCode', 'Country', 'Phone', 'PhoneExt', 'Fax',
|
|
490
|
+
'EmailAddress')
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def get_contacts(creds, domain):
|
|
494
|
+
result = call(creds, 'namecheap.domains.getContacts', DomainName=domain).find(NS + 'DomainContactsResult')
|
|
495
|
+
return {role: {field: text(result.find(NS + role), field) for field in CONTACT_FIELDS} for role in CONTACT_ROLES}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def cmd_contacts(args):
|
|
499
|
+
for role, fields in get_contacts(load_creds(), args.domain).items():
|
|
500
|
+
name = ' '.join(filter(None, (fields['FirstName'], fields['LastName'])))
|
|
501
|
+
place = ', '.join(filter(None, (fields['City'], fields['StateProvince'], fields['Country'])))
|
|
502
|
+
print(f" {role:<10} {name} <{fields['EmailAddress']}> {fields['OrganizationName']} {place}".rstrip())
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def cmd_available(args):
|
|
506
|
+
result = call(load_creds(), 'namecheap.domains.check', DomainList=','.join(args.domains))
|
|
507
|
+
for domain in result.iter(NS + 'DomainCheckResult'):
|
|
508
|
+
available = (domain.get('Available') or '').lower() == 'true'
|
|
509
|
+
premium = (domain.get('IsPremiumName') or '').lower() == 'true'
|
|
510
|
+
price = f" premium {domain.get('PremiumRegistrationPrice')}" if premium else ''
|
|
511
|
+
print(f" {domain.get('Domain'):<32} {'AVAILABLE' if available else 'taken'}{price}")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def tld_price(creds, tld, action, years=1):
|
|
515
|
+
response = call(creds, 'namecheap.users.getPricing', ProductType='DOMAIN',
|
|
516
|
+
ProductCategory=action.upper(), ProductName=tld)
|
|
517
|
+
for product in response.iter(NS + 'Product'):
|
|
518
|
+
for price in product.iter(NS + 'Price'):
|
|
519
|
+
if price.get('Duration') == str(years):
|
|
520
|
+
return price
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def cmd_price(args):
|
|
525
|
+
tld = args.tld.lstrip('.').lower()
|
|
526
|
+
price = tld_price(load_creds(), tld, args.action, args.years)
|
|
527
|
+
if price is None:
|
|
528
|
+
sys.exit(f'No {args.action} price for .{tld} ({args.years} year).')
|
|
529
|
+
print(f".{tld} {args.action} {args.years} year(s): {price.get('YourPrice') or price.get('Price')} {price.get('Currency')}"
|
|
530
|
+
f" (list {price.get('RegularPrice') or price.get('Price')}{', +ICANN fee ' + price.get('YourAdditonalCost') if price.get('YourAdditonalCost') else ''})")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def money_gate(creds, what, price_node, years, yes):
|
|
534
|
+
balance = call(creds, 'namecheap.users.getBalances').find(NS + 'UserGetBalancesResult')
|
|
535
|
+
cost = price_node.get('YourPrice') or price_node.get('Price') if price_node is not None else '?'
|
|
536
|
+
currency = price_node.get('Currency') if price_node is not None else ''
|
|
537
|
+
print(f"{what}: {cost} {currency} per year x {years}; account balance {balance.get('AvailableBalance')} {balance.get('Currency')}")
|
|
538
|
+
if not yes:
|
|
539
|
+
sys.exit('Not charged. This spends money: confirm the item and total with the user, then re-run with --yes.')
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def cmd_renew(args):
|
|
543
|
+
creds = load_creds()
|
|
544
|
+
_, tld = split_domain(args.domain)
|
|
545
|
+
money_gate(creds, f'Renew {args.domain}', tld_price(creds, tld, 'renew', 1), args.years, args.yes)
|
|
546
|
+
result = call(creds, 'namecheap.domains.renew', DomainName=args.domain, Years=args.years).find(NS + 'DomainRenewResult')
|
|
547
|
+
print(f"Renewed {args.domain}: charged {result.get('ChargedAmount')}, order {result.get('OrderID')}")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def cmd_register(args):
|
|
551
|
+
creds = load_creds()
|
|
552
|
+
_, tld = split_domain(args.domain)
|
|
553
|
+
check = call(creds, 'namecheap.domains.check', DomainList=args.domain).find(NS + 'DomainCheckResult')
|
|
554
|
+
if (check.get('Available') or '').lower() != 'true':
|
|
555
|
+
sys.exit(f'{args.domain} is not available.')
|
|
556
|
+
if (check.get('IsPremiumName') or '').lower() == 'true':
|
|
557
|
+
sys.exit(f"{args.domain} is a premium name ({check.get('PremiumRegistrationPrice')}); register it in the dashboard.")
|
|
558
|
+
contacts = get_contacts(creds, args.contacts_from)
|
|
559
|
+
money_gate(creds, f'Register {args.domain}', tld_price(creds, tld, 'register', 1), args.years, args.yes)
|
|
560
|
+
params = {'DomainName': args.domain, 'Years': args.years, 'AddFreeWhoisguard': 'yes', 'WGEnabled': 'yes'}
|
|
561
|
+
for role, fields in contacts.items():
|
|
562
|
+
for field, value in fields.items():
|
|
563
|
+
if value:
|
|
564
|
+
params[role + field] = value
|
|
565
|
+
if args.nameservers:
|
|
566
|
+
params['Nameservers'] = ','.join(args.nameservers)
|
|
567
|
+
result = call(creds, 'namecheap.domains.create', **params).find(NS + 'DomainCreateResult')
|
|
568
|
+
print(f"Registered {result.get('Domain')}: {result.get('Registered')}, charged {result.get('ChargedAmount')}, "
|
|
569
|
+
f"order {result.get('OrderID')}, privacy on")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def cmd_transfers(_args):
|
|
573
|
+
result = call(load_creds(), 'namecheap.domains.transfer.getList', PageSize=100).find(NS + 'TransferGetListResult')
|
|
574
|
+
transfers = list(result.iter(NS + 'Transfer')) if result is not None else []
|
|
575
|
+
print(f'{len(transfers)} transfer(s):')
|
|
576
|
+
for transfer in transfers:
|
|
577
|
+
print(f" {transfer.get('DomainName'):<32} {transfer.get('Status')} ({transfer.get('StatusDescription')}) "
|
|
578
|
+
f"id {transfer.get('ID')} {transfer.get('TransferDate')}")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def cmd_nameservers(args):
|
|
582
|
+
creds = load_creds()
|
|
583
|
+
sld, tld = split_domain(args.domain)
|
|
584
|
+
if args.set and args.default:
|
|
585
|
+
sys.exit('Use --set or --default, not both.')
|
|
586
|
+
if args.set:
|
|
587
|
+
if len(args.set) < 2:
|
|
588
|
+
sys.exit('Give at least two nameservers.')
|
|
589
|
+
print(f"{args.domain}: nameservers -> {', '.join(args.set)}. Namecheap's own records stop being served.")
|
|
590
|
+
if not args.yes:
|
|
591
|
+
sys.exit('Not changed: re-run with --yes (it moves the whole domain to another DNS host).')
|
|
592
|
+
call(creds, 'namecheap.domains.dns.setCustom', SLD=sld, TLD=tld, Nameservers=','.join(args.set))
|
|
593
|
+
elif args.default:
|
|
594
|
+
print(f'{args.domain}: nameservers -> Namecheap BasicDNS.')
|
|
595
|
+
if not args.yes:
|
|
596
|
+
sys.exit('Not changed: re-run with --yes (records at the current DNS host stop being served).')
|
|
597
|
+
call(creds, 'namecheap.domains.dns.setDefault', SLD=sld, TLD=tld)
|
|
598
|
+
result = call(creds, 'namecheap.domains.dns.getList', SLD=sld, TLD=tld).find(NS + 'DomainDNSGetListResult')
|
|
599
|
+
servers = [(server.text or '').strip() for server in result.iter(NS + 'Nameserver')]
|
|
600
|
+
kind = 'Namecheap DNS' if (result.get('IsUsingOurDNS') or '').lower() == 'true' else 'custom'
|
|
601
|
+
print(f"{args.domain}: {kind}: {', '.join(servers)}")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def cmd_lock(args):
|
|
605
|
+
creds = load_creds()
|
|
606
|
+
if args.on or args.off:
|
|
607
|
+
call(creds, 'namecheap.domains.setRegistrarLock', DomainName=args.domain, LockAction='LOCK' if args.on else 'UNLOCK')
|
|
608
|
+
result = call(creds, 'namecheap.domains.getRegistrarLock', DomainName=args.domain).find(NS + 'DomainGetRegistrarLockResult')
|
|
609
|
+
print(f"{args.domain}: registrar lock {result.get('RegistrarLockStatus')}")
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def cmd_privacy(args):
|
|
613
|
+
creds = load_creds()
|
|
614
|
+
result = call(creds, 'namecheap.whoisguard.getList', PageSize=100, ListType='ALL').find(NS + 'WhoisguardGetListResult')
|
|
615
|
+
entries = list(result.iter(NS + 'Whoisguard')) if result is not None else []
|
|
616
|
+
if args.domain:
|
|
617
|
+
entries = [entry for entry in entries if (entry.get('DomainName') or '').lower() == args.domain.lower()]
|
|
618
|
+
if not entries:
|
|
619
|
+
sys.exit(f'No domain privacy subscription found for {args.domain}.')
|
|
620
|
+
if args.on or args.off:
|
|
621
|
+
if not args.domain:
|
|
622
|
+
sys.exit('Name the domain to change.')
|
|
623
|
+
entry = entries[0]
|
|
624
|
+
if args.on:
|
|
625
|
+
email = args.email or get_contacts(creds, args.domain)['Registrant']['EmailAddress']
|
|
626
|
+
call(creds, 'namecheap.whoisguard.enable', WhoisguardID=entry.get('ID'), ForwardedToEmail=email)
|
|
627
|
+
else:
|
|
628
|
+
call(creds, 'namecheap.whoisguard.disable', WhoisguardID=entry.get('ID'))
|
|
629
|
+
return cmd_privacy(argparse.Namespace(domain=args.domain, on=False, off=False, email=None))
|
|
630
|
+
for entry in entries:
|
|
631
|
+
print(f" {entry.get('DomainName') or '(unassigned)':<32} {entry.get('Status'):<10} id {entry.get('ID')} expires {entry.get('Expires')}")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def cmd_lookup(args):
|
|
635
|
+
"""Resolve through Google and Cloudflare DNS-over-HTTPS; works where dig is not installed."""
|
|
636
|
+
for resolver in ('https://dns.google/resolve', 'https://cloudflare-dns.com/dns-query'):
|
|
637
|
+
url = f'{resolver}?' + urllib.parse.urlencode({'name': args.name, 'type': args.type.upper()})
|
|
638
|
+
request = urllib.request.Request(url, headers={'Accept': 'application/dns-json'})
|
|
639
|
+
try:
|
|
640
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
641
|
+
answers = json.load(response).get('Answer', [])
|
|
642
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
643
|
+
continue
|
|
644
|
+
records = [answer['data'] for answer in answers]
|
|
645
|
+
print(f"{args.name} {args.type.upper()} via {resolver.split('/')[2]}: " + (', '.join(records) or '(no answer)'))
|
|
646
|
+
if args.type.upper() == 'NS' and any('registrar-servers.com' in record for record in records):
|
|
647
|
+
print('-> Namecheap BasicDNS: use this tool.')
|
|
648
|
+
return
|
|
649
|
+
sys.exit('Both DNS-over-HTTPS resolvers were unreachable.')
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def cmd_restore(args):
|
|
653
|
+
saved = json.loads(Path(args.backup).read_text())
|
|
654
|
+
creds = load_creds()
|
|
655
|
+
state = get_hosts(creds, saved['domain'])
|
|
656
|
+
removed = [record for record in state['records'] if not any(same(record, old) for old in saved['records'])]
|
|
657
|
+
added = [record for record in saved['records'] if not any(same(record, live) for live in state['records'])]
|
|
658
|
+
apply(creds, saved['domain'], state, saved['records'], removed, added, args.dry_run, email_type=saved['email_type'])
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def main():
|
|
662
|
+
parser = argparse.ArgumentParser(prog='namecheap', description=__doc__,
|
|
663
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
664
|
+
sub = parser.add_subparsers(dest='command', required=True)
|
|
665
|
+
|
|
666
|
+
p = sub.add_parser('setup', help='store API credentials, then check them')
|
|
667
|
+
p.add_argument('api_user')
|
|
668
|
+
p.add_argument('api_key', nargs='?', help='omit to be prompted (keeps it out of shell history)')
|
|
669
|
+
p.add_argument('--username')
|
|
670
|
+
p.add_argument('--client-ip')
|
|
671
|
+
p.set_defaults(func=cmd_setup)
|
|
672
|
+
|
|
673
|
+
sub.add_parser('check', help='verify credentials and list domains').set_defaults(func=cmd_check)
|
|
674
|
+
|
|
675
|
+
sub.add_parser('balance', help='account balance').set_defaults(func=cmd_balance)
|
|
676
|
+
|
|
677
|
+
p = sub.add_parser('domains', help='list domains in the account')
|
|
678
|
+
p.add_argument('--expiring', action='store_true')
|
|
679
|
+
p.add_argument('--expired', action='store_true')
|
|
680
|
+
p.add_argument('--search')
|
|
681
|
+
p.set_defaults(func=cmd_domains)
|
|
682
|
+
|
|
683
|
+
p = sub.add_parser('info', help='status, expiry, privacy, nameservers, lock')
|
|
684
|
+
p.add_argument('domain')
|
|
685
|
+
p.set_defaults(func=cmd_info)
|
|
686
|
+
|
|
687
|
+
p = sub.add_parser('contacts', help='WHOIS contacts on a domain')
|
|
688
|
+
p.add_argument('domain')
|
|
689
|
+
p.set_defaults(func=cmd_contacts)
|
|
690
|
+
|
|
691
|
+
p = sub.add_parser('available', help='is a domain free to register')
|
|
692
|
+
p.add_argument('domains', nargs='+')
|
|
693
|
+
p.set_defaults(func=cmd_available)
|
|
694
|
+
|
|
695
|
+
p = sub.add_parser('price', help='your price for a TLD')
|
|
696
|
+
p.add_argument('tld')
|
|
697
|
+
p.add_argument('--action', choices=('register', 'renew', 'transfer'), default='register')
|
|
698
|
+
p.add_argument('--years', type=int, default=1)
|
|
699
|
+
p.set_defaults(func=cmd_price)
|
|
700
|
+
|
|
701
|
+
p = sub.add_parser('register', help='buy a domain (price first; --yes to charge)')
|
|
702
|
+
p.add_argument('domain')
|
|
703
|
+
p.add_argument('--contacts-from', required=True, metavar='OWNED_DOMAIN', help='copy WHOIS contacts from a domain you own')
|
|
704
|
+
p.add_argument('--years', type=int, default=1)
|
|
705
|
+
p.add_argument('--nameservers', nargs='+', help='custom nameservers (default: Namecheap BasicDNS)')
|
|
706
|
+
p.add_argument('--yes', action='store_true', help='actually charge the account')
|
|
707
|
+
p.set_defaults(func=cmd_register)
|
|
708
|
+
|
|
709
|
+
p = sub.add_parser('renew', help='renew a domain (price first; --yes to charge)')
|
|
710
|
+
p.add_argument('domain')
|
|
711
|
+
p.add_argument('--years', type=int, default=1)
|
|
712
|
+
p.add_argument('--yes', action='store_true', help='actually charge the account')
|
|
713
|
+
p.set_defaults(func=cmd_renew)
|
|
714
|
+
|
|
715
|
+
sub.add_parser('transfers', help='incoming domain transfers').set_defaults(func=cmd_transfers)
|
|
716
|
+
|
|
717
|
+
p = sub.add_parser('nameservers', help='show or change nameservers')
|
|
718
|
+
p.add_argument('domain')
|
|
719
|
+
p.add_argument('--set', nargs='+', metavar='NS')
|
|
720
|
+
p.add_argument('--default', action='store_true', help='back to Namecheap BasicDNS')
|
|
721
|
+
p.add_argument('--yes', action='store_true', help='confirm the switch')
|
|
722
|
+
p.set_defaults(func=cmd_nameservers)
|
|
723
|
+
|
|
724
|
+
p = sub.add_parser('lock', help='show or change the registrar lock')
|
|
725
|
+
p.add_argument('domain')
|
|
726
|
+
group = p.add_mutually_exclusive_group()
|
|
727
|
+
group.add_argument('--on', action='store_true')
|
|
728
|
+
group.add_argument('--off', action='store_true')
|
|
729
|
+
p.set_defaults(func=cmd_lock)
|
|
730
|
+
|
|
731
|
+
p = sub.add_parser('privacy', help='show or change free domain privacy')
|
|
732
|
+
p.add_argument('domain', nargs='?')
|
|
733
|
+
group = p.add_mutually_exclusive_group()
|
|
734
|
+
group.add_argument('--on', action='store_true')
|
|
735
|
+
group.add_argument('--off', action='store_true')
|
|
736
|
+
p.add_argument('--email', help='where privacy forwards mail (default: the registrant email)')
|
|
737
|
+
p.set_defaults(func=cmd_privacy)
|
|
738
|
+
|
|
739
|
+
p = sub.add_parser('list', help='show host records')
|
|
740
|
+
p.add_argument('domain')
|
|
741
|
+
p.set_defaults(func=cmd_list)
|
|
742
|
+
|
|
743
|
+
p = sub.add_parser('set', help='add/remove host records (merged, backed up, verified)')
|
|
744
|
+
p.add_argument('domain')
|
|
745
|
+
p.add_argument('--add', action='append', default=[], metavar='"TYPE HOST VALUE [TTL] [MXPREF]"')
|
|
746
|
+
p.add_argument('--remove', action='append', default=[], metavar='"TYPE HOST [VALUE]"')
|
|
747
|
+
p.add_argument('--replace', action='store_true', help='swap out records that conflict with an added one')
|
|
748
|
+
p.add_argument('--dry-run', action='store_true')
|
|
749
|
+
p.set_defaults(func=cmd_set)
|
|
750
|
+
|
|
751
|
+
p = sub.add_parser('vercel', help='point the apex + www (or --sub names) at Vercel')
|
|
752
|
+
p.add_argument('domain')
|
|
753
|
+
p.add_argument('--sub', action='append', metavar='NAME', help='subdomain(s) to CNAME to Vercel instead of @ + www')
|
|
754
|
+
p.add_argument('--dry-run', action='store_true')
|
|
755
|
+
p.set_defaults(func=cmd_vercel)
|
|
756
|
+
|
|
757
|
+
p = sub.add_parser('forward', help='list or change free email forwarding')
|
|
758
|
+
p.add_argument('domain')
|
|
759
|
+
p.add_argument('--add', action='append', default=[], metavar='mailbox=destination')
|
|
760
|
+
p.add_argument('--remove', action='append', default=[], metavar='mailbox')
|
|
761
|
+
p.add_argument('--force', action='store_true', help='allow switching a domain off MX mail to forwarding')
|
|
762
|
+
p.add_argument('--dry-run', action='store_true')
|
|
763
|
+
p.set_defaults(func=cmd_forward)
|
|
764
|
+
|
|
765
|
+
p = sub.add_parser('lookup', help='resolve a name over DNS-over-HTTPS (no dig needed)')
|
|
766
|
+
p.add_argument('name')
|
|
767
|
+
p.add_argument('type', nargs='?', default='A')
|
|
768
|
+
p.set_defaults(func=cmd_lookup)
|
|
769
|
+
|
|
770
|
+
p = sub.add_parser('restore', help='put a backup back')
|
|
771
|
+
p.add_argument('backup')
|
|
772
|
+
p.add_argument('--dry-run', action='store_true')
|
|
773
|
+
p.set_defaults(func=cmd_restore)
|
|
774
|
+
|
|
775
|
+
args = parser.parse_args()
|
|
776
|
+
try:
|
|
777
|
+
args.func(args)
|
|
778
|
+
except ApiError as error:
|
|
779
|
+
sys.exit(f'Namecheap API error: {error}')
|
|
780
|
+
except urllib.error.URLError as error:
|
|
781
|
+
sys.exit(f'Could not reach the Namecheap API: {error}')
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
if __name__ == '__main__':
|
|
785
|
+
main()
|