feat: replace domain port table with sequential domain diagnostics
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/93de7af8-10f9-438e-b9bc-8c6e9d39d787 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
13f15cb845
commit
86942ebc33
@@ -277,15 +277,10 @@ FEATURE_SERVICE_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Port requirements for service tiles (keyed by unit name or icon)
|
# Port requirements for service tiles (keyed by unit name or icon)
|
||||||
# Services using only 80/443 for domain access share the same base list.
|
_PORTS_MATRIX_FEDERATION = [
|
||||||
_PORTS_WEB = [
|
|
||||||
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
|
|
||||||
{"port": "443", "protocol": "TCP", "description": "HTTPS"},
|
|
||||||
]
|
|
||||||
_PORTS_MATRIX_FEDERATION = _PORTS_WEB + [
|
|
||||||
{"port": "8448", "protocol": "TCP", "description": "Matrix server-to-server federation"},
|
{"port": "8448", "protocol": "TCP", "description": "Matrix server-to-server federation"},
|
||||||
]
|
]
|
||||||
_PORTS_ELEMENT_CALLING = _PORTS_WEB + [
|
_PORTS_ELEMENT_CALLING = [
|
||||||
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
|
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
|
||||||
{"port": "7882-7894", "protocol": "UDP", "description": "LiveKit media streams"},
|
{"port": "7882-7894", "protocol": "UDP", "description": "LiveKit media streams"},
|
||||||
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
|
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
|
||||||
@@ -299,12 +294,12 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = {
|
|||||||
# Communication
|
# Communication
|
||||||
"matrix-synapse.service": _PORTS_MATRIX_FEDERATION,
|
"matrix-synapse.service": _PORTS_MATRIX_FEDERATION,
|
||||||
"livekit.service": _PORTS_ELEMENT_CALLING,
|
"livekit.service": _PORTS_ELEMENT_CALLING,
|
||||||
# Domain-based apps (80/443)
|
# Domain-based apps (80/443 handled by end-to-end domain reachability checks)
|
||||||
"btcpayserver.service": _PORTS_WEB,
|
"btcpayserver.service": [],
|
||||||
"vaultwarden.service": _PORTS_WEB,
|
"vaultwarden.service": [],
|
||||||
"phpfpm-nextcloud.service": _PORTS_WEB,
|
"phpfpm-nextcloud.service": [],
|
||||||
"phpfpm-wordpress.service": _PORTS_WEB,
|
"phpfpm-wordpress.service": [],
|
||||||
"haven-relay.service": _PORTS_WEB,
|
"haven-relay.service": [],
|
||||||
# SSH (only open when feature is enabled)
|
# SSH (only open when feature is enabled)
|
||||||
"sshd.service": [{"port": "22", "protocol": "TCP", "description": "SSH"}],
|
"sshd.service": [{"port": "22", "protocol": "TCP", "description": "SSH"}],
|
||||||
}
|
}
|
||||||
@@ -934,6 +929,185 @@ def _check_port_status(
|
|||||||
return "closed"
|
return "closed"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_domain_reachable(domain: str) -> dict:
|
||||||
|
"""Curl the domain to verify end-to-end HTTPS reachability."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "10", f"https://{domain}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
status_code = result.stdout.strip()
|
||||||
|
if status_code and status_code.isdigit() and int(status_code) > 0:
|
||||||
|
return {"reachable": True, "status_code": int(status_code)}
|
||||||
|
return {"reachable": False, "error": result.stderr.strip() or "No response"}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {"reachable": False, "error": "timeout"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"reachable": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_domain_checklist(domain: str | None, external_ip: str, internal_ip: str | None = None) -> dict:
|
||||||
|
"""Evaluate sequential domain diagnostics and return UI-ready checklist data."""
|
||||||
|
steps: list[dict] = []
|
||||||
|
domain_status: dict = {
|
||||||
|
"status": "not_set",
|
||||||
|
"resolved_ip": None,
|
||||||
|
"expected_ip": external_ip,
|
||||||
|
}
|
||||||
|
domain_reachable: dict | None = None
|
||||||
|
|
||||||
|
if not domain:
|
||||||
|
steps.append({
|
||||||
|
"step": 1,
|
||||||
|
"label": "Domain Configured",
|
||||||
|
"status": "error",
|
||||||
|
"detail": "No domain configured",
|
||||||
|
})
|
||||||
|
steps.append({
|
||||||
|
"step": 2,
|
||||||
|
"label": "DNS Points to Your Server",
|
||||||
|
"status": "skipped",
|
||||||
|
"detail": "Skipped until a domain is configured",
|
||||||
|
})
|
||||||
|
steps.append({
|
||||||
|
"step": 3,
|
||||||
|
"label": "Ports 80 & 443 Open",
|
||||||
|
"status": "skipped",
|
||||||
|
"detail": "Skipped until domain and DNS are configured",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"domain_status": domain_status,
|
||||||
|
"domain_reachable": domain_reachable,
|
||||||
|
"domain_check_steps": steps,
|
||||||
|
"has_issues": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.append({
|
||||||
|
"step": 1,
|
||||||
|
"label": "Domain Configured",
|
||||||
|
"status": "ok",
|
||||||
|
"detail": domain,
|
||||||
|
})
|
||||||
|
|
||||||
|
resolved_ip: str | None = None
|
||||||
|
try:
|
||||||
|
results = socket.getaddrinfo(domain, None)
|
||||||
|
if results:
|
||||||
|
resolved_ip = results[0][4][0]
|
||||||
|
except socket.gaierror:
|
||||||
|
resolved_ip = None
|
||||||
|
except Exception:
|
||||||
|
resolved_ip = None
|
||||||
|
|
||||||
|
if not resolved_ip:
|
||||||
|
domain_status = {
|
||||||
|
"status": "unresolvable",
|
||||||
|
"resolved_ip": None,
|
||||||
|
"expected_ip": external_ip,
|
||||||
|
}
|
||||||
|
steps.append({
|
||||||
|
"step": 2,
|
||||||
|
"label": "DNS Points to Your Server",
|
||||||
|
"status": "error",
|
||||||
|
"detail": "Domain does not resolve — check your DNS provider",
|
||||||
|
})
|
||||||
|
steps.append({
|
||||||
|
"step": 3,
|
||||||
|
"label": "Ports 80 & 443 Open",
|
||||||
|
"status": "skipped",
|
||||||
|
"detail": "Skipped until DNS resolves to your server",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"domain_status": domain_status,
|
||||||
|
"domain_reachable": domain_reachable,
|
||||||
|
"domain_check_steps": steps,
|
||||||
|
"has_issues": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if external_ip == "unavailable":
|
||||||
|
domain_status = {
|
||||||
|
"status": "error",
|
||||||
|
"resolved_ip": resolved_ip,
|
||||||
|
"expected_ip": external_ip,
|
||||||
|
}
|
||||||
|
steps.append({
|
||||||
|
"step": 2,
|
||||||
|
"label": "DNS Points to Your Server",
|
||||||
|
"status": "warning",
|
||||||
|
"detail": f"Resolves to {resolved_ip} (external IP unavailable for comparison)",
|
||||||
|
})
|
||||||
|
elif resolved_ip != external_ip:
|
||||||
|
domain_status = {
|
||||||
|
"status": "dns_mismatch",
|
||||||
|
"resolved_ip": resolved_ip,
|
||||||
|
"expected_ip": external_ip,
|
||||||
|
}
|
||||||
|
steps.append({
|
||||||
|
"step": 2,
|
||||||
|
"label": "DNS Points to Your Server",
|
||||||
|
"status": "error",
|
||||||
|
"detail": f"Resolves to {resolved_ip} but your external IP is {external_ip}",
|
||||||
|
})
|
||||||
|
steps.append({
|
||||||
|
"step": 3,
|
||||||
|
"label": "Ports 80 & 443 Open",
|
||||||
|
"status": "skipped",
|
||||||
|
"detail": "Skipped until DNS points to your external IP",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"domain_status": domain_status,
|
||||||
|
"domain_reachable": domain_reachable,
|
||||||
|
"domain_check_steps": steps,
|
||||||
|
"has_issues": True,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
domain_status = {
|
||||||
|
"status": "connected",
|
||||||
|
"resolved_ip": resolved_ip,
|
||||||
|
"expected_ip": external_ip,
|
||||||
|
}
|
||||||
|
steps.append({
|
||||||
|
"step": 2,
|
||||||
|
"label": "DNS Points to Your Server",
|
||||||
|
"status": "ok",
|
||||||
|
"detail": f"Resolves to {resolved_ip} (matches your external IP)",
|
||||||
|
})
|
||||||
|
|
||||||
|
domain_reachable = _check_domain_reachable(domain)
|
||||||
|
if domain_reachable.get("reachable"):
|
||||||
|
status_code = domain_reachable.get("status_code")
|
||||||
|
steps.append({
|
||||||
|
"step": 3,
|
||||||
|
"label": "Ports 80 & 443 Open",
|
||||||
|
"status": "ok",
|
||||||
|
"detail": f"HTTPS reachable (HTTP {status_code})",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
internal = internal_ip or "your server"
|
||||||
|
error_text = domain_reachable.get("error") or "No response"
|
||||||
|
steps.append({
|
||||||
|
"step": 3,
|
||||||
|
"label": "Ports 80 & 443 Open",
|
||||||
|
"status": "error",
|
||||||
|
"detail": (
|
||||||
|
f"Could not reach https://{domain} ({error_text}).\n"
|
||||||
|
f"→ Forward ports 80 & 443 on your router to {internal}\n"
|
||||||
|
"→ This only needs to be done once — all domain services share these ports\n"
|
||||||
|
"→ Test from your phone on mobile data (your home network may not support hairpin NAT / loopback)"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
has_issues = any(step.get("status") == "error" for step in steps[:3])
|
||||||
|
return {
|
||||||
|
"domain_status": domain_status,
|
||||||
|
"domain_reachable": domain_reachable,
|
||||||
|
"domain_check_steps": steps,
|
||||||
|
"has_issues": has_issues,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── QR code helper ────────────────────────────────────────────────
|
# ── QR code helper ────────────────────────────────────────────────
|
||||||
|
|
||||||
def _generate_qr_base64(data: str) -> str | None:
|
def _generate_qr_base64(data: str) -> str | None:
|
||||||
@@ -2184,19 +2358,14 @@ async def api_services():
|
|||||||
break
|
break
|
||||||
has_domain_issues = False
|
has_domain_issues = False
|
||||||
if needs_domain:
|
if needs_domain:
|
||||||
if not domain:
|
domain_eval = await loop.run_in_executor(
|
||||||
has_domain_issues = True
|
None,
|
||||||
else:
|
_evaluate_domain_checklist,
|
||||||
try:
|
domain,
|
||||||
results = socket.getaddrinfo(domain, None)
|
_cached_external_ip,
|
||||||
if not results:
|
None,
|
||||||
has_domain_issues = True
|
)
|
||||||
else:
|
has_domain_issues = bool(domain_eval.get("has_issues"))
|
||||||
resolved_ip = results[0][4][0]
|
|
||||||
if _cached_external_ip != "unavailable" and resolved_ip != _cached_external_ip:
|
|
||||||
has_domain_issues = True
|
|
||||||
except (socket.gaierror, Exception):
|
|
||||||
has_domain_issues = True
|
|
||||||
health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy"
|
health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy"
|
||||||
# Check Bitcoin IBD state
|
# Check Bitcoin IBD state
|
||||||
if unit == "bitcoind.service" and enabled:
|
if unit == "bitcoind.service" and enabled:
|
||||||
@@ -2347,57 +2516,23 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
|||||||
external_ip = _cached_external_ip
|
external_ip = _cached_external_ip
|
||||||
_save_internal_ip(internal_ip)
|
_save_internal_ip(internal_ip)
|
||||||
|
|
||||||
# Domain status check
|
# Domain diagnostics (sequential checklist)
|
||||||
domain_status: dict | None = None
|
domain_status: dict | None = None
|
||||||
|
domain_reachable: dict | None = None
|
||||||
|
domain_check_steps: list[dict] = []
|
||||||
|
has_domain_issues = False
|
||||||
if needs_domain:
|
if needs_domain:
|
||||||
if domain:
|
domain_eval = await loop.run_in_executor(
|
||||||
def _check_one_domain(d: str) -> dict:
|
None,
|
||||||
try:
|
_evaluate_domain_checklist,
|
||||||
results = socket.getaddrinfo(d, None)
|
domain,
|
||||||
if not results:
|
external_ip,
|
||||||
return {
|
internal_ip,
|
||||||
"status": "unresolvable",
|
)
|
||||||
"resolved_ip": None,
|
domain_status = domain_eval.get("domain_status")
|
||||||
"expected_ip": external_ip,
|
domain_reachable = domain_eval.get("domain_reachable")
|
||||||
}
|
domain_check_steps = domain_eval.get("domain_check_steps", [])
|
||||||
resolved_ip = results[0][4][0]
|
has_domain_issues = bool(domain_eval.get("has_issues"))
|
||||||
if external_ip == "unavailable":
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"resolved_ip": resolved_ip,
|
|
||||||
"expected_ip": external_ip,
|
|
||||||
}
|
|
||||||
if resolved_ip == external_ip:
|
|
||||||
return {
|
|
||||||
"status": "connected",
|
|
||||||
"resolved_ip": resolved_ip,
|
|
||||||
"expected_ip": external_ip,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"status": "dns_mismatch",
|
|
||||||
"resolved_ip": resolved_ip,
|
|
||||||
"expected_ip": external_ip,
|
|
||||||
}
|
|
||||||
except socket.gaierror:
|
|
||||||
return {
|
|
||||||
"status": "unresolvable",
|
|
||||||
"resolved_ip": None,
|
|
||||||
"expected_ip": external_ip,
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"resolved_ip": None,
|
|
||||||
"expected_ip": external_ip,
|
|
||||||
}
|
|
||||||
|
|
||||||
domain_status = await loop.run_in_executor(None, _check_one_domain, domain)
|
|
||||||
else:
|
|
||||||
domain_status = {
|
|
||||||
"status": "not_set",
|
|
||||||
"resolved_ip": None,
|
|
||||||
"expected_ip": external_ip,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Port requirements and statuses
|
# Port requirements and statuses
|
||||||
port_requirements = SERVICE_PORT_REQUIREMENTS.get(unit, [])
|
port_requirements = SERVICE_PORT_REQUIREMENTS.get(unit, [])
|
||||||
@@ -2417,6 +2552,38 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
|||||||
"status": ps,
|
"status": ps,
|
||||||
"description": p.get("description", ""),
|
"description": p.get("description", ""),
|
||||||
})
|
})
|
||||||
|
extra_ports = port_statuses if unit in ("matrix-synapse.service", "livekit.service") else []
|
||||||
|
|
||||||
|
if needs_domain and unit in ("matrix-synapse.service", "livekit.service"):
|
||||||
|
if has_domain_issues:
|
||||||
|
domain_check_steps.append({
|
||||||
|
"step": 4,
|
||||||
|
"label": "Federation Port" if unit == "matrix-synapse.service" else "Additional Ports Required",
|
||||||
|
"status": "skipped",
|
||||||
|
"detail": "Skipped until Steps 1-3 are complete",
|
||||||
|
})
|
||||||
|
elif unit == "matrix-synapse.service":
|
||||||
|
if extra_ports:
|
||||||
|
matrix_open = extra_ports[0]["status"] != "closed"
|
||||||
|
domain_check_steps.append({
|
||||||
|
"step": 4,
|
||||||
|
"label": "Federation Port",
|
||||||
|
"status": "ok" if matrix_open else "error",
|
||||||
|
"detail": (
|
||||||
|
f"Matrix federation port 8448 (TCP) is {'open' if matrix_open else 'closed'}.\n"
|
||||||
|
f"Matrix federation requires port 8448 (TCP) forwarded to {internal_ip}"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
extra_open = all(p["status"] != "closed" for p in extra_ports)
|
||||||
|
domain_check_steps.append({
|
||||||
|
"step": 4,
|
||||||
|
"label": "Additional Ports Required",
|
||||||
|
"status": "ok" if extra_open else "error",
|
||||||
|
"detail": (
|
||||||
|
"Element-Call/LiveKit requires additional forwarded ports for WebRTC and TURN traffic."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
# Compute composite health
|
# Compute composite health
|
||||||
sync_progress: float | None = None
|
sync_progress: float | None = None
|
||||||
@@ -2427,12 +2594,6 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
|||||||
health = "disabled"
|
health = "disabled"
|
||||||
elif status == "active":
|
elif status == "active":
|
||||||
has_port_issues = any(p["status"] == "closed" for p in port_statuses)
|
has_port_issues = any(p["status"] == "closed" for p in port_statuses)
|
||||||
has_domain_issues = False
|
|
||||||
if needs_domain:
|
|
||||||
if not domain:
|
|
||||||
has_domain_issues = True
|
|
||||||
elif domain_status and domain_status.get("status") not in ("connected", None):
|
|
||||||
has_domain_issues = True
|
|
||||||
health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy"
|
health = "needs_attention" if (has_port_issues or has_domain_issues) else "healthy"
|
||||||
# Check Bitcoin IBD state
|
# Check Bitcoin IBD state
|
||||||
if unit == "bitcoind.service" and enabled:
|
if unit == "bitcoind.service" and enabled:
|
||||||
@@ -2499,8 +2660,11 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
|||||||
"domain": domain,
|
"domain": domain,
|
||||||
"domain_name": domain_key,
|
"domain_name": domain_key,
|
||||||
"domain_status": domain_status,
|
"domain_status": domain_status,
|
||||||
|
"domain_reachable": domain_reachable,
|
||||||
|
"domain_check_steps": domain_check_steps,
|
||||||
"port_requirements": port_requirements,
|
"port_requirements": port_requirements,
|
||||||
"port_statuses": port_statuses,
|
"port_statuses": port_statuses,
|
||||||
|
"extra_ports": extra_ports,
|
||||||
"external_ip": external_ip,
|
"external_ip": external_ip,
|
||||||
"internal_ip": internal_ip,
|
"internal_ip": internal_ip,
|
||||||
"feature": feature_entry,
|
"feature": feature_entry,
|
||||||
|
|||||||
@@ -102,9 +102,71 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
// Section C: Ports (only if service has port_requirements)
|
// Section C: Domain diagnostics (domain services)
|
||||||
if (data.port_statuses && data.port_statuses.length > 0) {
|
if (data.needs_domain) {
|
||||||
var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; });
|
var steps = data.domain_check_steps || [];
|
||||||
|
var stepsHtml = "";
|
||||||
|
steps.forEach(function(step) {
|
||||||
|
var iconLabel = "—";
|
||||||
|
if (step.status === "ok") iconLabel = "✅";
|
||||||
|
else if (step.status === "error") iconLabel = "❌";
|
||||||
|
else if (step.status === "warning") iconLabel = "⚠️";
|
||||||
|
else if (step.status === "skipped") iconLabel = "⏭️";
|
||||||
|
var detail = escHtml(step.detail || "").replace(/\n/g, "<br>");
|
||||||
|
stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' +
|
||||||
|
'<strong>' + iconLabel + ' Step ' + escHtml(String(step.step)) + ': ' + escHtml(step.label || "") + '</strong>' +
|
||||||
|
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
var domainActionHtml = "";
|
||||||
|
var ds = data.domain_status || {};
|
||||||
|
if (!data.domain && data.domain_name) {
|
||||||
|
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-config-domain-btn">🌐 Configure Domain</button>';
|
||||||
|
} else if (data.domain && (ds.status === "dns_mismatch" || ds.status === "unresolvable")) {
|
||||||
|
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-reconfig-domain-btn">🔄 Reconfigure Domain</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="svc-detail-section">' +
|
||||||
|
'<div class="svc-detail-section-title">Domain Diagnostic Checklist</div>' +
|
||||||
|
stepsHtml +
|
||||||
|
domainActionHtml +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) {
|
||||||
|
var extraRows = "";
|
||||||
|
data.extra_ports.forEach(function(p) {
|
||||||
|
var statusIcon, statusClass2;
|
||||||
|
if (p.status === "listening") {
|
||||||
|
statusIcon = "✅ Open";
|
||||||
|
statusClass2 = "port-status-listening";
|
||||||
|
} else if (p.status === "firewall_open") {
|
||||||
|
statusIcon = "🟡 Firewall open";
|
||||||
|
statusClass2 = "port-status-open";
|
||||||
|
} else if (p.status === "closed") {
|
||||||
|
statusIcon = "❌ Closed";
|
||||||
|
statusClass2 = "port-status-closed";
|
||||||
|
} else {
|
||||||
|
statusIcon = "— Unknown";
|
||||||
|
statusClass2 = "port-status-unknown";
|
||||||
|
}
|
||||||
|
extraRows += '<tr>' +
|
||||||
|
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
|
||||||
|
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
|
||||||
|
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
|
||||||
|
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
html += '<div class="svc-detail-section">' +
|
||||||
|
'<div class="svc-detail-section-title">Step 4: Additional Ports</div>' +
|
||||||
|
'<table class="svc-detail-port-table">' +
|
||||||
|
'<thead><tr><th>Port</th><th>Protocol</th><th>Description</th><th>Status</th></tr></thead>' +
|
||||||
|
'<tbody>' + extraRows + '</tbody>' +
|
||||||
|
'</table>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
} else if (data.port_statuses && data.port_statuses.length > 0) {
|
||||||
|
// Non-domain services (SSH) keep local single-port checks.
|
||||||
var portTableRows = "";
|
var portTableRows = "";
|
||||||
data.port_statuses.forEach(function(p) {
|
data.port_statuses.forEach(function(p) {
|
||||||
var statusIcon, statusClass2;
|
var statusIcon, statusClass2;
|
||||||
@@ -121,137 +183,19 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
statusIcon = "— Unknown";
|
statusIcon = "— Unknown";
|
||||||
statusClass2 = "port-status-unknown";
|
statusClass2 = "port-status-unknown";
|
||||||
}
|
}
|
||||||
var desc = p.description;
|
|
||||||
var portNum = parseInt(p.port, 10);
|
|
||||||
if (portNum === 80 || portNum === 443) {
|
|
||||||
desc += " (shared — all services)";
|
|
||||||
}
|
|
||||||
portTableRows += '<tr>' +
|
portTableRows += '<tr>' +
|
||||||
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
|
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
|
||||||
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
|
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
|
||||||
'<td class="svc-detail-port-table-desc">' + escHtml(desc) + '</td>' +
|
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
|
||||||
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
|
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
});
|
});
|
||||||
|
|
||||||
var troubleshootHtml = "";
|
|
||||||
if (anyPortClosed) {
|
|
||||||
var sharedPorts = [];
|
|
||||||
var specificPorts = [];
|
|
||||||
data.port_statuses.forEach(function(p) {
|
|
||||||
if (p.status === "closed") {
|
|
||||||
var portNum = parseInt(p.port, 10);
|
|
||||||
if (portNum === 80 || portNum === 443) {
|
|
||||||
sharedPorts.push(p);
|
|
||||||
} else {
|
|
||||||
specificPorts.push(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var troubleParts = [];
|
|
||||||
|
|
||||||
if (sharedPorts.length > 0) {
|
|
||||||
troubleParts.push(
|
|
||||||
'<strong>⚠️ Ports 80 and 443 need to be forwarded on your router.</strong>' +
|
|
||||||
'<p style="margin-top:8px">These are <strong>shared system ports</strong> — you only need to set them up once and they cover all your domain-based services ' +
|
|
||||||
'(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).</p>' +
|
|
||||||
'<p style="margin-top:8px">If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:</p>' +
|
|
||||||
'<ol>' +
|
|
||||||
'<li>Log into your router\'s admin panel (usually <code>http://192.168.1.1</code>)</li>' +
|
|
||||||
'<li>Find the <strong>Port Forwarding</strong> section</li>' +
|
|
||||||
'<li>Forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong> to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
|
|
||||||
'<li>Save your router settings</li>' +
|
|
||||||
'</ol>' +
|
|
||||||
'<p style="margin-top:8px">💡 Once these two ports are forwarded, you won\'t see this warning on any service again.</p>'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (specificPorts.length > 0) {
|
|
||||||
var portList = specificPorts.map(function(p) {
|
|
||||||
return '<strong>' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ')</strong> — ' + escHtml(p.description);
|
|
||||||
}).join('<br>');
|
|
||||||
|
|
||||||
troubleParts.push(
|
|
||||||
'<strong>⚠️ This service requires additional ports to be forwarded:</strong>' +
|
|
||||||
'<p style="margin-top:8px">' + portList + '</p>' +
|
|
||||||
'<ol>' +
|
|
||||||
'<li>Log into your router\'s admin panel</li>' +
|
|
||||||
'<li>Forward each port listed above to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
|
|
||||||
'<li>Save your router settings</li>' +
|
|
||||||
'</ol>'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
troubleshootHtml = '<div class="svc-detail-troubleshoot">' + troubleParts.join('<hr style="border:none;border-top:1px solid rgba(255,255,255,0.1);margin:16px 0">') + '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '<div class="svc-detail-section">' +
|
html += '<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">Port Status</div>' +
|
'<div class="svc-detail-section-title">Port Status</div>' +
|
||||||
'<table class="svc-detail-port-table">' +
|
'<table class="svc-detail-port-table">' +
|
||||||
'<thead><tr>' +
|
'<thead><tr><th>Port</th><th>Protocol</th><th>Description</th><th>Status</th></tr></thead>' +
|
||||||
'<th>Port</th><th>Protocol</th><th>Description</th><th>Status</th>' +
|
|
||||||
'</tr></thead>' +
|
|
||||||
'<tbody>' + portTableRows + '</tbody>' +
|
'<tbody>' + portTableRows + '</tbody>' +
|
||||||
'</table>' +
|
'</table>' +
|
||||||
troubleshootHtml +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Section D: Domain (only if service needs_domain)
|
|
||||||
if (data.needs_domain) {
|
|
||||||
var domainStatusHtml = "";
|
|
||||||
var ds = data.domain_status || {};
|
|
||||||
var domainBadge = "";
|
|
||||||
|
|
||||||
if (data.domain) {
|
|
||||||
if (ds.status === "connected") {
|
|
||||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--ok">✓ ' + escHtml(data.domain) + '</span></span>';
|
|
||||||
} else if (ds.status === "dns_mismatch") {
|
|
||||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">⚠ ' + escHtml(data.domain) + ' (IP mismatch)</span></span>';
|
|
||||||
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
|
||||||
'<strong>⚠️ Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.</strong>' +
|
|
||||||
'<p style="margin-top:8px">This usually means the DNS record needs to be updated:</p>' +
|
|
||||||
'<ol>' +
|
|
||||||
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
|
|
||||||
'<li>Find your domain and check the Dynamic DNS record</li>' +
|
|
||||||
'<li>Make sure it points to your current external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
|
|
||||||
'<li>If you set up a DDNS curl command during onboarding, verify it\'s running correctly</li>' +
|
|
||||||
'</ol>' +
|
|
||||||
'</div>';
|
|
||||||
} else if (ds.status === "unresolvable") {
|
|
||||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--error">✗ ' + escHtml(data.domain) + ' (DNS error)</span></span>';
|
|
||||||
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
|
||||||
'<strong>⚠️ This domain cannot be resolved. DNS is not configured yet.</strong>' +
|
|
||||||
'<p style="margin-top:8px">Let\'s get it set up:</p>' +
|
|
||||||
'<ol>' +
|
|
||||||
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
|
|
||||||
'<li>Find the domain you purchased for this service</li>' +
|
|
||||||
'<li>Create a Dynamic DNS record pointing to your external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
|
|
||||||
'<li>Copy the DDNS curl command from Njal.la\'s dashboard</li>' +
|
|
||||||
'</ol>' +
|
|
||||||
'<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-reconfig-domain-btn">🔄 Reconfigure Domain</button>' +
|
|
||||||
'</div>';
|
|
||||||
} else {
|
|
||||||
domainBadge = '<span class="svc-detail-domain-value">' + escHtml(data.domain) + '</span>';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">Not configured</span></span>';
|
|
||||||
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
|
||||||
'<strong>⚠️ No domain has been configured for this service yet.</strong>' +
|
|
||||||
'<p style="margin-top:8px">To get this service working:</p>' +
|
|
||||||
'<ol>' +
|
|
||||||
'<li>Purchase a subdomain at <a href="https://njal.la" target="_blank">njal.la</a> (if you haven\'t already)</li>' +
|
|
||||||
'<li>Use the button below to configure your domain through the setup wizard</li>' +
|
|
||||||
'</ol>' +
|
|
||||||
'<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-config-domain-btn">🌐 Configure Domain</button>' +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '<div class="svc-detail-section">' +
|
|
||||||
'<div class="svc-detail-section-title">Domain</div>' +
|
|
||||||
domainBadge +
|
|
||||||
domainStatusHtml +
|
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ in
|
|||||||
|
|
||||||
path = [
|
path = [
|
||||||
pkgs.qrencode
|
pkgs.qrencode
|
||||||
|
pkgs.curl
|
||||||
pkgs.iproute2
|
pkgs.iproute2
|
||||||
pkgs.nftables
|
pkgs.nftables
|
||||||
pkgs.iptables
|
pkgs.iptables
|
||||||
|
|||||||
Reference in New Issue
Block a user