Require unique hostname for Wallet Connections; add conflict validation and UI guidance

This commit is contained in:
copilot-swe-agent[bot]
2026-07-27 11:36:02 +00:00
committed by GitHub
parent 007bf1a4ce
commit 768f26027e
4 changed files with 634 additions and 11 deletions
+79 -1
View File
@@ -4091,15 +4091,93 @@ def _validate_safe_name(name: str) -> bool:
return bool(name) and _SAFE_NAME_RE.match(name) is not None
# Hostname characters: letters, digits, hyphens, dots (no underscores in FQDNs)
_HOSTNAME_RE = re.compile(r'^[a-z0-9]([a-z0-9\-\.]*[a-z0-9])?$')
# Managed domain keys that produce Caddy virtual-host blocks (excluding sslemail)
_MANAGED_DOMAIN_KEYS: frozenset[str] = frozenset([
"matrix", "haven", "element-calling", "vaultwarden",
"btcpayserver", "nextcloud", "wordpress", "lightning",
])
# Any save involving lightning must be unique across all managed service domains.
_LIGHTNING_DOMAIN_KEY = "lightning"
def _normalize_hostname(raw: str) -> str:
"""Trim, lowercase, and remove exactly one trailing dot."""
h = raw.strip().lower()
if h.endswith("."):
h = h[:-1]
return h
def _validate_hostname(hostname: str) -> bool:
"""Return True if hostname is a valid safe FQDN-style value."""
return bool(hostname) and _HOSTNAME_RE.match(hostname) is not None
def _read_managed_domain(key: str) -> str | None:
"""Read the stored hostname for a managed domain key, or None if absent/empty."""
try:
with open(os.path.join(DOMAINS_DIR, key), "r") as fh:
val = fh.read().strip()
return _normalize_hostname(val) if val else None
except OSError:
return None
def _check_domain_conflict(domain_name: str, new_hostname: str) -> str | None:
"""Return the conflicting managed key if new_hostname is already used by another key.
The uniqueness rule is applied symmetrically: if either the target key or the
conflicting candidate key is 'lightning', the check is enforced.
"""
for key in _MANAGED_DOMAIN_KEYS:
if key == domain_name:
continue # skip self; re-saving the same hostname is allowed
existing = _read_managed_domain(key)
if existing is None:
continue
if existing == new_hostname:
# Enforce when lightning is involved (either side)
if domain_name == _LIGHTNING_DOMAIN_KEY or key == _LIGHTNING_DOMAIN_KEY:
return key
return None
@app.post("/api/domains/set")
async def api_domains_set(req: DomainSetRequest):
"""Save a domain and optionally register a DDNS URL."""
if not _validate_safe_name(req.domain_name):
raise HTTPException(status_code=400, detail="Invalid domain_name")
# Normalize and validate the submitted hostname before any mutation.
normalized = _normalize_hostname(req.domain)
if not _validate_hostname(normalized):
raise HTTPException(status_code=400, detail="Invalid hostname value")
# Reject duplicate managed-domain hostnames when lightning is involved.
if req.domain_name in _MANAGED_DOMAIN_KEYS:
conflicting_key = _check_domain_conflict(req.domain_name, normalized)
if conflicting_key is not None:
raise HTTPException(
status_code=409,
detail={
"error": "domain_conflict",
"conflicting_domain_key": conflicting_key,
"message": (
"Wallet Connections requires its own unique hostname. "
"Choose a new subdomain such as lightning.yourdomain.com. "
f"This hostname is already assigned to: {conflicting_key}."
),
},
)
_ensure_domains_dir()
domain_path = os.path.join(DOMAINS_DIR, req.domain_name)
with open(domain_path, "w") as f:
f.write(req.domain.strip())
f.write(normalized)
_chown_to_caddy(domain_path)
if req.ddns_url:
+39 -9
View File
@@ -59,6 +59,8 @@ function openDomainSetupModal(feat, onSaved) {
if (!$domainSetupModal) return;
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
var npubField = "";
if (feat.id === "haven") {
var currentNpub = "";
@@ -73,6 +75,17 @@ function openDomainSetupModal(feat, onSaved) {
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
}
var nwcWarning = isWalletConnections
? '<div style="margin-bottom:14px;padding:10px 14px;background:rgba(255,180,0,0.10);border:1px solid var(--warning-color,#f59e0b);border-radius:8px;font-size:0.88rem;line-height:1.6;">' +
'<strong>⚠ Wallet Connections requires its own unique hostname.</strong> ' +
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
'</div>'
: '';
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
var introHtml;
if (_currentRole === "node") {
introHtml =
@@ -89,6 +102,7 @@ function openDomainSetupModal(feat, onSaved) {
$domainSetupBody.innerHTML =
'<div class="domain-setup-intro">' +
nwcWarning +
introHtml +
'<details style="margin-top:10px;">' +
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
@@ -96,11 +110,11 @@ function openDomainSetupModal(feat, onSaved) {
'<li>In Njal.la, open a domain you own and click &quot;Add record&quot;.</li>' +
'<li>Set record type to <strong>Dynamic</strong>.</li>' +
'<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
'(Example only, your choice — for &quot;call.yourdomain.com&quot; you&apos;d type just: &nbsp;<code>call</code>)<br>' +
'(Example only, your choice — for &quot;' + domainLabelExample + '&quot; you&apos;d type just: &nbsp;<code>' + (isWalletConnections ? 'lightning' : 'call') + '</code>)<br>' +
'&#9888; Do NOT type the full domain here — Njal.la adds it automatically.</li>' +
'<li>A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.</li>' +
'<li>Copy the curl command Njal.la gives you, e.g.:<br>' +
'<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=call.yourdomain.com&amp;k=abc123&amp;auto&quot;</code></li>' +
'<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=' + domainLabelExample + '&amp;k=abc123&amp;auto&quot;</code></li>' +
'</ol>' +
'</details>' +
'<details style="margin-top:6px;">' +
@@ -111,10 +125,10 @@ function openDomainSetupModal(feat, onSaved) {
'<li>Copy the curl command Njal.la gives you.</li>' +
'</ol>' +
'</details>' +
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. call.yourdomain.com) or a separate domain (e.g. call.com) — and paste its curl command.</p>' +
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
'</div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=' + domainPlaceholder + '&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
npubField +
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save &amp; Enable</button></div>';
@@ -150,7 +164,8 @@ function openDomainSetupModal(feat, onSaved) {
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = "Save & Enable";
alert("Failed to save domain. Please try again.");
var msg = (err && err.message) ? err.message : "Failed to save domain. Please try again.";
alert(msg);
}
});
@@ -161,6 +176,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
if (!$domainSetupModal) return;
if ($domainSetupTitle) $domainSetupTitle.textContent = "🔄 Reconfigure Domain — " + feat.name;
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
var npubField = "";
if (feat.id === "haven") {
var currentNpub = "";
@@ -175,11 +192,23 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
}
var nwcWarning = isWalletConnections
? '<div style="margin-bottom:14px;padding:10px 14px;background:rgba(255,180,0,0.10);border:1px solid var(--warning-color,#f59e0b);border-radius:8px;font-size:0.88rem;line-height:1.6;">' +
'<strong>⚠ Wallet Connections requires its own unique hostname.</strong> ' +
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
'</div>'
: '';
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
var externalIp = _cachedExternalIp || "your external IP";
var currentDomain = existingDomain || "";
$domainSetupBody.innerHTML =
'<div class="domain-setup-intro">' +
nwcWarning +
'<p>Your domain <strong>' + escHtml(currentDomain || "this domain") + '</strong> is configured but isn\'t resolving correctly.</p>' +
'<p><strong>Troubleshooting steps:</strong></p>' +
'<ol>' +
@@ -191,8 +220,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
'</ol>' +
'</div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" value="' + escHtml(currentDomain) + '" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" value="' + escHtml(currentDomain) + '" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=' + domainPlaceholder + '&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
npubField +
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save &amp; Update</button></div>';
@@ -228,7 +257,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = "Save & Update";
alert("Failed to save domain. Please try again.");
var msg = (err && err.message) ? err.message : "Failed to save domain. Please try again.";
alert(msg);
}
});
+10 -1
View File
@@ -55,7 +55,16 @@ async function apiFetch(path, options) {
const res = await fetch(path, options || {});
if (!res.ok) {
let detail = res.status + " " + res.statusText;
try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {}
try {
const body = await res.json();
if (body && body.detail) {
if (typeof body.detail === "string") {
detail = body.detail;
} else if (body.detail && typeof body.detail.message === "string") {
detail = body.detail.message;
}
}
} catch (e) {}
throw new Error(detail);
}
return res.json();