Fix Namecheap credential handling

Stop persisting Namecheap API keys in ~/.namecheap-api, require the key from environment variables for runtime use, and replace raw exception output in setup/runtime paths with safe guidance. Update the skill documentation to match the new setup flow.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Aaron Powell
2026-07-08 12:36:12 +10:00
parent 0e208f59fc
commit e8768cb156
2 changed files with 131 additions and 56 deletions
+5 -6
View File
@@ -15,11 +15,11 @@ DO NOT USE FOR: domain registration/purchase, SSL certificate management, hostin
Before executing any API commands, verify credentials are configured:
1. **Check for existing config** — look for `~/.namecheap-api`
1. **Check for existing config** — look for `~/.namecheap-api` and ensure `NAMECHEAP_API_KEY` is exported in the user's shell
2. If not configured, guide the user through setup:
a. **Show public IP** — run `python3 namecheap.py public-ip` to display the user's public IP
b. **Instruct IP whitelisting** — tell the user to go to https://ap.www.namecheap.com/settings/tools/apiaccess/, enable API (select ON), and whitelist the displayed IP
c. **Have the user run setup themselves** — ask the user to run `python3 namecheap.py setup` directly **in their own terminal**. The script prompts for the username and reads the API key with a hidden prompt (`getpass`), writes `~/.namecheap-api` with `chmod 600`, and validates the connection. **Never ask the user to paste their API key into the chat, and never log, echo, or display the API key value.** If you cannot run an interactive terminal for the user, instruct them to run `setup` themselves, or to export `NAMECHEAP_API_USER` and `NAMECHEAP_API_KEY` as environment variables in their own shell — rather than collecting the secret via `ask_user`.
c. **Have the user run setup themselves** — ask the user to run `python3 namecheap.py setup` directly **in their own terminal**. The script prompts for the username, reads the API key with a hidden prompt (`getpass`), saves only the username to `~/.namecheap-api` with `chmod 600`, and validates the connection without writing the API key to disk. **Never ask the user to paste their API key into the chat, and never log, echo, or display the API key value.** After setup, the user must export `NAMECHEAP_API_KEY` in their own shell before running API commands. If you cannot run an interactive terminal for the user, instruct them to run `setup` themselves, or to export `NAMECHEAP_API_USER` and `NAMECHEAP_API_KEY` in their own shell — rather than collecting the secret via `ask_user`.
d. **Confirm** — once the user reports setup succeeded, proceed with DNS operations.
### DNS Operations
@@ -102,7 +102,7 @@ python3 namecheap.py domains.ns.update --domain example.com --nameserver ns1.exa
## Behavior
- **Always check credentials first.** Before any API operation, verify `~/.namecheap-api` exists and is readable. If not, run the setup flow.
- **Always check credentials first.** Before any API operation, verify `~/.namecheap-api` exists and is readable, and that `NAMECHEAP_API_KEY` is set in the current shell. If not, run the setup flow and have the user export the API key.
- **Show current records before modifying.** Before adding or removing records, always fetch and display the current DNS records so the user can confirm the change.
- **Use `ask_user` to confirm destructive changes.** Before removing records or replacing all records with `setHosts`, confirm with the user.
- **The Namecheap `setHosts` API replaces ALL records.** Never call `domains.dns.setHosts` directly unless you have fetched all existing records first. Use `dns.addHost` and `dns.removeHost` for safe single-record operations — they handle the fetch-modify-write cycle internally.
@@ -111,14 +111,13 @@ python3 namecheap.py domains.ns.update --domain example.com --nameserver ns1.exa
## Credential Storage
Credentials are stored in `~/.namecheap-api`:
The Namecheap username is stored in `~/.namecheap-api`:
```bash
NAMECHEAP_API_USER="username"
NAMECHEAP_API_KEY="api-key-here"
```
This file must have `600` permissions (owner read/write only). Alternatively, the script reads credentials from the `NAMECHEAP_API_USER` and `NAMECHEAP_API_KEY` environment variables, which take precedence over the file when both are set.
This file must have `600` permissions (owner read/write only). The API key is **not** stored on disk; the script reads `NAMECHEAP_API_KEY` from the environment and uses it only in memory. `NAMECHEAP_API_USER` may be supplied either in the file or in the environment, and environment variables take precedence when both are set. If a legacy `NAMECHEAP_API_KEY` line already exists in `~/.namecheap-api`, it should be removed after the user exports the key securely in their shell.
## Supported Record Types
+126 -50
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env python3
"""Namecheap API CLI wrapper for DNS management.
Uses only the Python standard library (no third-party dependencies). Credentials
are read from ``~/.namecheap-api`` (or env vars) and are never passed on the
command line, so they cannot leak via ``ps``/shell history.
Uses only the Python standard library (no third-party dependencies). The
username can be cached in ``~/.namecheap-api`` while the API key is read from
the ``NAMECHEAP_API_KEY`` environment variable (or prompted during setup) and
is never passed on the command line, so it cannot leak via ``ps``/shell
history.
"""
import argparse
@@ -75,47 +77,107 @@ class NamecheapError(Exception):
# --- Configuration --------------------------------------------------------
def _redact_sensitive_text(text):
text = re.sub(r'(?i)(ApiKey=)[^&\s]+', r"\1[REDACTED]", text)
text = re.sub(r'(?i)(NAMECHEAP_API_KEY\s*=\s*"?)[^"\n]+("?)', r"\1[REDACTED]\2", text)
return text
def _read_config_values():
"""Return shell-style KEY="value" pairs from the local config file."""
if not os.path.isfile(CONFIG_FILE):
return {}
with open(CONFIG_FILE, "r", encoding="utf-8") as fh:
content = fh.read()
pattern = re.compile(r'^\s*([A-Z_]+)\s*=\s*"?([^"\n]*)"?\s*$', re.MULTILINE)
return {m.group(1): m.group(2) for m in pattern.finditer(content)}
def load_config():
"""Return (api_user, api_key), preferring env vars then the config file."""
"""Return (api_user, api_key), preferring env vars for sensitive values."""
api_user = os.environ.get("NAMECHEAP_API_USER")
api_key = os.environ.get("NAMECHEAP_API_KEY")
if api_user and api_key:
return api_user, api_key
if os.path.isfile(CONFIG_FILE):
with open(CONFIG_FILE, "r", encoding="utf-8") as fh:
content = fh.read()
# File uses shell-style KEY="value" lines for backward compatibility.
pattern = re.compile(r'^\s*([A-Z_]+)\s*=\s*"?([^"\n]*)"?\s*$', re.MULTILINE)
values = {m.group(1): m.group(2) for m in pattern.finditer(content)}
api_user = api_user or values.get("NAMECHEAP_API_USER")
api_key = api_key or values.get("NAMECHEAP_API_KEY")
values = _read_config_values()
api_user = api_user or values.get("NAMECHEAP_API_USER")
return api_user, api_key
def has_legacy_api_key():
"""Return True when a legacy clear-text API key is still present on disk."""
return bool(_read_config_values().get("NAMECHEAP_API_KEY"))
def save_config(api_user):
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
fh.write("# Cached Namecheap username for this skill.\n")
fh.write("# Export NAMECHEAP_API_KEY in your shell before running API commands.\n")
fh.write(f'NAMECHEAP_API_USER="{api_user}"\n')
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 600
def print_safe_api_failure(prefix, public_ip=None):
err(prefix)
print("Please verify:")
print(" 1. API access is enabled (ON) at the Namecheap settings page")
if public_ip:
print(f" 2. IP address {public_ip} is whitelisted")
else:
print(" 2. Your current public IP is whitelisted")
print(" 3. Your username and API key are correct")
def print_safe_namecheap_error(exc):
message = _redact_sensitive_text(str(exc)).strip()
if message:
err(f"API returned error: {message}")
else:
err("API returned an error.")
def print_safe_runtime_error(exc):
if isinstance(exc, urllib.error.URLError):
err("Network error while contacting Namecheap or resolving your public IP.")
return
if isinstance(exc, OSError):
err(f"Could not read or update {CONFIG_FILE}.")
return
if isinstance(exc, ET.ParseError):
err("Received an invalid XML response from the Namecheap API.")
return
if isinstance(exc, json.JSONDecodeError):
err("Could not parse the JSON input file that was provided.")
return
err("The operation failed.")
def print_credential_help(legacy_key_present=False):
err("Namecheap API credentials are not configured.")
print()
print("Run 'python3 namecheap.py setup' to save your Namecheap username.")
print("Then export NAMECHEAP_API_KEY in your own shell before running API commands.")
print()
print("You need:")
print(" 1. Your Namecheap username")
print(" 2. An API key from: https://ap.www.namecheap.com/settings/tools/apiaccess/")
print(" 3. Your public IP whitelisted in the API settings")
if legacy_key_present:
print()
warn(f"A legacy API key entry in {CONFIG_FILE} is ignored for security reasons.")
print("Remove the NAMECHEAP_API_KEY line from that file after exporting the key securely.")
def check_credentials():
api_user, api_key = load_config()
legacy_key_present = has_legacy_api_key()
if not api_user or not api_key:
err("Namecheap API credentials not configured.")
print()
print("Run 'python3 namecheap.py setup' to configure your credentials.")
print()
print("You need:")
print(" 1. Your Namecheap username")
print(" 2. An API key from: https://ap.www.namecheap.com/settings/tools/apiaccess/")
print(" 3. Your public IP whitelisted in the API settings")
print_credential_help(legacy_key_present)
sys.exit(1)
return api_user, api_key
def save_config(api_user, api_key):
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
fh.write(f'NAMECHEAP_API_USER="{api_user}"\n')
fh.write(f'NAMECHEAP_API_KEY="{api_key}"\n')
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 600
# --- Networking -----------------------------------------------------------
def _http_get(url, timeout=15):
@@ -224,23 +286,38 @@ def cmd_setup(_args):
print()
existing_user, existing_key = load_config()
if existing_user and existing_key:
info(f"Existing configuration found for user: {existing_user}")
print("\nTesting API connection...")
try:
api_request("domains.getList", {"PageSize": "1"})
success("API connection successful!")
except Exception as exc: # noqa: BLE001
err(f"API connection failed: {exc}")
if has_legacy_api_key():
warn(f"A legacy API key was found in {CONFIG_FILE} and will no longer be used.")
print("Run setup to rewrite the file without the API key, then export NAMECHEAP_API_KEY securely.")
print()
answer = input("Update stored credentials? [y/N]: ").strip().lower()
if existing_user:
info(f"Existing configuration found for user: {existing_user}")
if existing_key:
print("\nTesting API connection...")
try:
api_request("domains.getList", {"PageSize": "1"})
success("API connection successful!")
except NamecheapError:
print_safe_api_failure("API connection failed.", public_ip)
except (urllib.error.URLError, OSError, ET.ParseError, json.JSONDecodeError) as exc:
print_safe_runtime_error(exc)
else:
info("No NAMECHEAP_API_KEY environment variable is set yet.")
print("The setup flow will validate the API key you enter, but it will not write the key to disk.")
print()
answer = input("Update saved username or validate a new API key? [y/N]: ").strip().lower()
if answer not in ("y", "yes"):
info("Keeping existing credentials.")
return
print()
print("Enter your Namecheap credentials:\n")
api_user = input(" API Username: ").strip()
prompt = " API Username"
if existing_user:
prompt += f" [{existing_user}]"
prompt += ": "
api_user = input(prompt).strip() or existing_user or ""
api_key = getpass.getpass(" API Key (hidden): ").strip()
print()
@@ -248,8 +325,9 @@ def cmd_setup(_args):
err("Both username and API key are required.")
sys.exit(1)
save_config(api_user, api_key)
success(f"Credentials saved to {CONFIG_FILE}")
save_config(api_user)
success(f"Username saved to {CONFIG_FILE}")
info("The API key was not written to disk. Export NAMECHEAP_API_KEY in your shell for future commands.")
print("\nTesting API connection...")
try:
# Use the just-entered credentials directly for the validation call.
@@ -257,12 +335,10 @@ def cmd_setup(_args):
os.environ["NAMECHEAP_API_KEY"] = api_key
api_request("domains.getList", {"PageSize": "1"})
success("API connection successful!")
except Exception as exc: # noqa: BLE001
warn("API connection failed. Please verify:")
print(" 1. API access is enabled (ON) at the Namecheap settings page")
print(f" 2. IP address {public_ip} is whitelisted")
print(" 3. Your API key is correct")
print(f" (details: {exc})")
except NamecheapError:
print_safe_api_failure("API connection failed.", public_ip)
except (urllib.error.URLError, OSError, ET.ParseError, json.JSONDecodeError) as exc:
print_safe_runtime_error(exc)
def cmd_domains_list(args):
@@ -682,13 +758,13 @@ def main(argv=None):
try:
args.func(args)
except NamecheapError as exc:
err(f"API returned error: {exc}")
print_safe_namecheap_error(exc)
return 1
except urllib.error.URLError as exc:
err(f"Network error: {exc}")
print_safe_runtime_error(exc)
return 1
except (OSError, ET.ParseError, json.JSONDecodeError) as exc:
err(str(exc))
print_safe_runtime_error(exc)
return 1
return 0