diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py
index 2ecfa9d..0cd84bb 100644
--- a/app/sovran_systemsos_web/server.py
+++ b/app/sovran_systemsos_web/server.py
@@ -4091,15 +4091,96 @@ def _validate_safe_name(name: str) -> bool:
return bool(name) and _SAFE_NAME_RE.match(name) is not None
+# Hostname characters: letters, digits, hyphens only within labels; dots separate labels.
+# Each label must start and end with a letter or digit; no consecutive dots.
+_HOSTNAME_RE = re.compile(
+ r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[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:
diff --git a/app/sovran_systemsos_web/static/css/domain-setup.css b/app/sovran_systemsos_web/static/css/domain-setup.css
index 8b0e770..2db406e 100644
--- a/app/sovran_systemsos_web/static/css/domain-setup.css
+++ b/app/sovran_systemsos_web/static/css/domain-setup.css
@@ -171,3 +171,15 @@ domain-field-actions {
.port-req-status {
font-weight: 600;
}
+
+/* ── Wallet Connections unique-hostname warning ───────────────────── */
+
+.domain-nwc-warning {
+ 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;
+}
diff --git a/app/sovran_systemsos_web/static/js/features.js b/app/sovran_systemsos_web/static/js/features.js
index 666f9c7..fcb7b8d 100644
--- a/app/sovran_systemsos_web/static/js/features.js
+++ b/app/sovran_systemsos_web/static/js/features.js
@@ -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 = '
';
}
+ var nwcWarning = isWalletConnections
+ ? '
' +
+ '⚠ Wallet Connections requires its own unique hostname. ' +
+ 'Use a new subdomain such as lightning.yourdomain.com, or a separate domain. ' +
+ 'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
+ '
'
+ : '';
+
+ 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 =
'
In Njal.la, open a domain you own and click "Add record".
' +
'
Set record type to Dynamic.
' +
'
In the Name field, type ONLY the host part — the word before your domain. ' +
- '(Example only, your choice — for "call.yourdomain.com" you'd type just: call) ' +
+ '(Example only, your choice — for "' + domainLabelExample + '" you'd type just: ' + (isWalletConnections ? 'lightning' : 'call') + ') ' +
'⚠ Do NOT type the full domain here — Njal.la adds it automatically.
' +
'
A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.
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.
' +
+ '
Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.
' +
'
' +
- '' +
- '
ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record
' +
+ '' +
+ '
ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record
' +
npubField +
'';
@@ -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 = '';
}
+ var nwcWarning = isWalletConnections
+ ? '
' +
+ '⚠ Wallet Connections requires its own unique hostname. ' +
+ 'Use a new subdomain such as lightning.yourdomain.com, or a separate domain. ' +
+ 'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
+ '