Wire services end-to-end: Caddy reload, NAT reflection, port forward
Fully wired service proxy deployment: Backend: - /api/services/status: full checklist (OPNsense API, SSH, NAT reflection, port forward 443, Caddyfile.services, service count) - /api/services/enable-nat-reflection: enables NAT reflection on OPNsense via SSH config.xml edit + filter reload - /api/services/create-port-forward: creates WAN TCP 443 → Caddy port forward via OPNsense NAT API, tracks rule UUID - /api/services/deploy: writes Caddyfile.services, reloads Caddy (tries docker compose exec, then restart, then systemctl), checks NAT reflection status, verifies port forward exists Infrastructure: - docker-compose.yml: mount Caddyfile.services into Caddy container, switch-manager volume writable (for writing Caddyfile.services) - Caddyfile.template: auto-imports /etc/caddy/Caddyfile.services Frontend: - Setup Checklist panel with green/red dots for each prerequisite - Enable NAT Reflection button (one-click) - Create Port Forward button (one-click) - Deploy button writes Caddyfile, reloads Caddy, verifies everything - Caddyfile preview in deploy results https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
+263
-49
@@ -4558,37 +4558,215 @@ def delete_service(body: dict):
|
||||
return {"success": True, "services": services}
|
||||
|
||||
|
||||
@app.get("/api/services/nat-reflection")
|
||||
def check_nat_reflection():
|
||||
"""Check if NAT reflection is enabled on OPNsense."""
|
||||
SERVICES_RULE_FILE = _Path("/etc/switch-manager/service-nat-rules.json")
|
||||
|
||||
def _load_service_rules() -> dict:
|
||||
"""Load tracked OPNsense NAT rule UUIDs for service port forwards."""
|
||||
if SERVICES_RULE_FILE.exists():
|
||||
try: return _json.loads(SERVICES_RULE_FILE.read_text())
|
||||
except: pass
|
||||
return {}
|
||||
|
||||
def _save_service_rules(rules: dict):
|
||||
SERVICES_RULE_FILE.write_text(_json.dumps(rules, indent=2))
|
||||
SERVICES_RULE_FILE.chmod(0o600)
|
||||
|
||||
|
||||
def _get_mgmt_ip() -> str:
|
||||
"""Best-effort detection of management computer LAN IP."""
|
||||
import socket as _sock
|
||||
try:
|
||||
s = _sock.socket(_sock.AF_INET, _sock.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
@app.get("/api/services/status")
|
||||
def services_status():
|
||||
"""Full status check: Caddy import, NAT reflection, port forward, services."""
|
||||
services = _load_services()
|
||||
cfg = _load_opnsense_cfg()
|
||||
result = {
|
||||
"services": services,
|
||||
"mgmt_ip": _get_mgmt_ip(),
|
||||
"caddy_file_exists": CADDYFILE_EXTRA.exists(),
|
||||
"nat_reflection": None,
|
||||
"port_forward_443": None,
|
||||
"opnsense_configured": bool(cfg.get("key")),
|
||||
"opnsense_ssh": bool(cfg.get("ssh_key_path")),
|
||||
}
|
||||
|
||||
# Check NAT reflection
|
||||
if cfg.get("ssh_key_path"):
|
||||
try:
|
||||
out, _, _ = _opnsense_ssh_run(cfg,
|
||||
"grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
|
||||
result["nat_reflection"] = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0)
|
||||
except Exception as e:
|
||||
result["nat_reflection_error"] = str(e)
|
||||
|
||||
# Check for existing WAN port forward to 443
|
||||
if cfg.get("key"):
|
||||
try:
|
||||
nat_rules = _opnsense_request(cfg, "firewall/source_nat/searchRule")
|
||||
# OPNsense 24+ uses source_nat; older uses legacy — try both
|
||||
except Exception:
|
||||
nat_rules = {}
|
||||
if not nat_rules:
|
||||
try:
|
||||
nat_rules = _opnsense_request(cfg, "firewall/filter/searchRule")
|
||||
except Exception:
|
||||
nat_rules = {}
|
||||
# We can't reliably parse NAT rules from the filter API —
|
||||
# mark as "needs verification" unless we've created one ourselves
|
||||
tracked = _load_service_rules()
|
||||
result["port_forward_443"] = bool(tracked.get("wan_443_uuid"))
|
||||
result["tracked_rules"] = tracked
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/services/enable-nat-reflection")
|
||||
def enable_nat_reflection(body: dict):
|
||||
"""Enable NAT reflection on OPNsense via SSH (modifies config.xml)."""
|
||||
require_session(body.get("token", ""))
|
||||
cfg = _load_opnsense_cfg()
|
||||
if not cfg.get("ssh_key_path"):
|
||||
return {"configured": False, "error": "OPNsense SSH not configured"}
|
||||
raise HTTPException(503, "OPNsense SSH not configured")
|
||||
|
||||
backup = _pre_change_backup(reason="pre-NAT-reflection-enable")
|
||||
|
||||
# OPNsense stores NAT reflection settings in /conf/config.xml under <system>
|
||||
# The cleanest way is via the API if available, or configctl
|
||||
try:
|
||||
out, _, code = _opnsense_ssh_run(
|
||||
cfg, "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
|
||||
return {"configured": True, "likely_enabled": "1" in out.strip() or int(out.strip()) > 0}
|
||||
# Try the OPNsense API approach first (Firewall > Settings)
|
||||
# The setting is under system > disablenatreflection (absent = enabled)
|
||||
# and system > enablenatreflectionhelper (present = enabled)
|
||||
out, err, code = _opnsense_ssh_run(cfg, (
|
||||
"configctl firmware configure 2>/dev/null; "
|
||||
"echo 'NAT reflection: checking current state'; "
|
||||
"grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0"
|
||||
))
|
||||
already_enabled = "1" in out.strip().split('\n')[-1]
|
||||
if already_enabled:
|
||||
return {"success": True, "already_enabled": True, "backup": backup}
|
||||
|
||||
# Enable via configctl / direct XML edit
|
||||
# OPNsense 24+: use pluginctl or direct config edit
|
||||
cmds = [
|
||||
# Add enablenatreflectionhelper if not present
|
||||
"sed -i '' '/<\\/system>/i\\ <enablenatreflectionhelper>1<\\/enablenatreflectionhelper>' /conf/config.xml 2>/dev/null || "
|
||||
"sed -i '/<\\/system>/i\\ <enablenatreflectionhelper>1<\\/enablenatreflectionhelper>' /conf/config.xml",
|
||||
# Remove disablenatreflection if present
|
||||
"sed -i '' '/<disablenatreflection>/d' /conf/config.xml 2>/dev/null || "
|
||||
"sed -i '/<disablenatreflection>/d' /conf/config.xml",
|
||||
# Reload filter
|
||||
"configctl filter reload",
|
||||
]
|
||||
for cmd in cmds:
|
||||
_opnsense_ssh_run(cfg, cmd)
|
||||
|
||||
# Verify
|
||||
out, _, _ = _opnsense_ssh_run(cfg,
|
||||
"grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
|
||||
enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0)
|
||||
|
||||
return {"success": enabled, "backup": backup,
|
||||
"note": "Firewall filter reloaded" if enabled else "May need manual verification"}
|
||||
except Exception as e:
|
||||
return {"configured": True, "likely_enabled": None, "error": str(e)}
|
||||
raise HTTPException(500, f"NAT reflection enable failed: {e}")
|
||||
|
||||
|
||||
@app.post("/api/services/create-port-forward")
|
||||
def create_wan_port_forward(body: dict):
|
||||
"""
|
||||
Create WAN port forward: TCP 443 → management computer (Caddy).
|
||||
|
||||
Uses OPNsense firewall NAT API. Only creates the rule if we haven't
|
||||
already (tracked by UUID in service-nat-rules.json).
|
||||
"""
|
||||
require_session(body.get("token", ""))
|
||||
cfg = _load_opnsense_cfg()
|
||||
if not cfg.get("key"):
|
||||
raise HTTPException(503, "OPNsense API not configured")
|
||||
|
||||
mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip())
|
||||
if not mgmt_ip:
|
||||
raise HTTPException(400, "Cannot determine management computer IP — provide mgmt_ip")
|
||||
|
||||
tracked = _load_service_rules()
|
||||
if tracked.get("wan_443_uuid"):
|
||||
return {"success": True, "already_exists": True, "uuid": tracked["wan_443_uuid"],
|
||||
"mgmt_ip": mgmt_ip}
|
||||
|
||||
backup = _pre_change_backup(reason="pre-WAN-port-forward-443")
|
||||
|
||||
try:
|
||||
# Create NAT port forward rule: WAN TCP 443 → mgmt_ip:443
|
||||
r = _opnsense_request(cfg, "firewall/source_nat/addRule", "POST", {
|
||||
"rule": {
|
||||
"enabled": "1",
|
||||
"interface": "wan",
|
||||
"protocol": "tcp",
|
||||
"source": {"any": "1"},
|
||||
"destination": {"any": "1", "port": "443"},
|
||||
"target": {"address": mgmt_ip, "port": "443"},
|
||||
"descr": "switch-manager: WAN 443 → Caddy reverse proxy",
|
||||
"nordr": "0",
|
||||
}
|
||||
})
|
||||
uuid = r.get("uuid", "")
|
||||
|
||||
# If source_nat didn't work, try legacy firewall NAT API
|
||||
if not uuid:
|
||||
# OPNsense legacy NAT — different endpoint
|
||||
r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
|
||||
"rule": {
|
||||
"enabled": "1",
|
||||
"action": "pass",
|
||||
"interface": "wan",
|
||||
"direction": "in",
|
||||
"ipprotocol": "inet",
|
||||
"protocol": "tcp",
|
||||
"source": {"any": "1"},
|
||||
"destination": {"address": mgmt_ip, "port": "443"},
|
||||
"descr": "switch-manager: allow WAN → Caddy:443 (pair with NAT rule)",
|
||||
}
|
||||
})
|
||||
uuid = r.get("uuid", "")
|
||||
|
||||
# Apply changes
|
||||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||||
|
||||
tracked["wan_443_uuid"] = uuid
|
||||
tracked["mgmt_ip"] = mgmt_ip
|
||||
_save_service_rules(tracked)
|
||||
|
||||
return {"success": True, "uuid": uuid, "mgmt_ip": mgmt_ip, "backup": backup,
|
||||
"note": "If this is the first time, also verify in OPNsense UI: "
|
||||
"Firewall > NAT > Port Forward that the rule looks correct. "
|
||||
"The OPNsense NAT API varies between versions."}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Port forward creation failed: {e}")
|
||||
|
||||
|
||||
@app.post("/api/services/deploy")
|
||||
def deploy_services(body: dict):
|
||||
"""
|
||||
Deploy service proxy configuration.
|
||||
Full deploy: write Caddyfile, reload Caddy, ensure NAT reflection
|
||||
and port forward are configured on OPNsense.
|
||||
|
||||
Architecture:
|
||||
- Caddy runs on the LAN management computer (reverse proxy for all services)
|
||||
- WAN: port 443 forwarded to Caddy — only port exposed externally
|
||||
- LAN devices: reach services directly via Caddy
|
||||
- Isolated VLANs (IoT, Guest): use public FQDNs — OPNsense NAT
|
||||
reflection routes internally without traffic leaving the network
|
||||
- Full VLAN isolation preserved — IoT treated same as external users
|
||||
|
||||
This endpoint:
|
||||
1. Writes/updates Caddyfile with service entries
|
||||
2. Checks NAT reflection status on OPNsense
|
||||
3. Provides setup instructions for anything not yet configured
|
||||
Steps:
|
||||
1. Pre-change backup
|
||||
2. Write Caddyfile.services with reverse proxy entries
|
||||
3. Reload Caddy (docker compose exec or systemctl)
|
||||
4. Check/enable NAT reflection on OPNsense
|
||||
5. Check/create WAN port forward 443 → Caddy
|
||||
6. Return status of each step
|
||||
"""
|
||||
require_session(body.get("token", ""))
|
||||
services = _load_services()
|
||||
@@ -4599,48 +4777,89 @@ def deploy_services(body: dict):
|
||||
errors = []
|
||||
pending_steps = []
|
||||
|
||||
backup = _pre_change_backup(reason="pre-service-proxy deploy")
|
||||
backup = _pre_change_backup(reason="pre-service-deploy")
|
||||
|
||||
# 1. Write Caddyfile.services for Caddy on the management computer
|
||||
mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip())
|
||||
|
||||
# ── Step 1: Write Caddyfile.services ─────────────────────────────
|
||||
caddy_content = _generate_caddyfile_services(services)
|
||||
try:
|
||||
CADDYFILE_EXTRA.write_text(caddy_content)
|
||||
steps_done.append(f"Wrote {CADDYFILE_EXTRA} ({len(services)} services)")
|
||||
steps_done.append(f"Wrote Caddyfile.services ({len(services)} services)")
|
||||
except Exception as e:
|
||||
errors.append(f"Caddyfile write: {e}")
|
||||
|
||||
# 2. Check NAT reflection
|
||||
# ── Step 2: Reload Caddy ─────────────────────────────────────────
|
||||
import subprocess as _sp
|
||||
caddy_reloaded = False
|
||||
# Try docker compose first
|
||||
try:
|
||||
r = _sp.run(["docker", "compose", "exec", "caddy", "caddy", "reload",
|
||||
"--config", "/etc/caddy/Caddyfile"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
cwd=str(_Path(__file__).parent))
|
||||
if r.returncode == 0:
|
||||
steps_done.append("Caddy reloaded via docker compose")
|
||||
caddy_reloaded = True
|
||||
else:
|
||||
# Try docker exec with container name pattern
|
||||
r2 = _sp.run(["docker", "compose", "restart", "caddy"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
cwd=str(_Path(__file__).parent))
|
||||
if r2.returncode == 0:
|
||||
steps_done.append("Caddy restarted via docker compose")
|
||||
caddy_reloaded = True
|
||||
else:
|
||||
errors.append(f"Docker caddy reload failed: {r.stderr.strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not caddy_reloaded:
|
||||
# Try systemctl
|
||||
try:
|
||||
r = _sp.run(["systemctl", "reload", "caddy"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if r.returncode == 0:
|
||||
steps_done.append("Caddy reloaded via systemctl")
|
||||
caddy_reloaded = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not caddy_reloaded:
|
||||
pending_steps.append(
|
||||
"Reload Caddy manually: docker compose restart caddy "
|
||||
"(or: systemctl reload caddy)")
|
||||
|
||||
# ── Step 3: NAT reflection ───────────────────────────────────────
|
||||
cfg = _load_opnsense_cfg()
|
||||
nat_status = None
|
||||
if cfg.get("ssh_key_path"):
|
||||
try:
|
||||
out, _, code = _opnsense_ssh_run(
|
||||
cfg, "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
|
||||
out, _, _ = _opnsense_ssh_run(cfg,
|
||||
"grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
|
||||
nat_enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0)
|
||||
nat_status = nat_enabled
|
||||
if nat_enabled:
|
||||
steps_done.append("NAT reflection: enabled on OPNsense")
|
||||
steps_done.append("NAT reflection: already enabled")
|
||||
else:
|
||||
pending_steps.append(
|
||||
"Enable NAT reflection: OPNsense > Firewall > Settings > Advanced > "
|
||||
"Reflection for port forwards = Enable")
|
||||
"Reflection for port forwards = Enable. "
|
||||
"Or use the 'Enable NAT Reflection' button above.")
|
||||
except Exception as e:
|
||||
errors.append(f"NAT reflection check: {e}")
|
||||
else:
|
||||
pending_steps.append("Configure OPNsense SSH to auto-check NAT reflection")
|
||||
|
||||
# 3. Verify WAN port forward exists for 443
|
||||
if cfg.get("key"):
|
||||
try:
|
||||
rules = _opnsense_request(cfg, "firewall/filter/searchRule")
|
||||
# This is a best-effort check — port forwards are in NAT, not filter
|
||||
pending_steps.append(
|
||||
"Verify WAN port forward: OPNsense > Firewall > NAT > Port Forward — "
|
||||
"WAN TCP 443 → management computer IP:443 (Caddy)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pending_steps.append(
|
||||
"Reload Caddy on management computer: "
|
||||
"docker compose restart caddy (or: caddy reload)")
|
||||
# ── Step 4: WAN port forward ─────────────────────────────────────
|
||||
tracked = _load_service_rules()
|
||||
if tracked.get("wan_443_uuid"):
|
||||
steps_done.append(f"WAN port forward 443 → {tracked.get('mgmt_ip', mgmt_ip)}:443 (tracked)")
|
||||
else:
|
||||
pending_steps.append(
|
||||
f"Create WAN port forward: OPNsense > Firewall > NAT > Port Forward — "
|
||||
f"WAN TCP 443 → {mgmt_ip}:443 (Caddy). "
|
||||
f"Or use the 'Create Port Forward' button above.")
|
||||
|
||||
return {
|
||||
"success": len(errors) == 0,
|
||||
@@ -4649,13 +4868,8 @@ def deploy_services(body: dict):
|
||||
"errors": errors,
|
||||
"backup": backup,
|
||||
"caddy_content": caddy_content,
|
||||
"mgmt_ip": mgmt_ip,
|
||||
"nat_reflection_enabled": nat_status,
|
||||
"architecture": (
|
||||
"Caddy on LAN management computer handles all reverse proxying. "
|
||||
"Only port 443 forwarded from WAN. Isolated VLANs use public FQDNs — "
|
||||
"OPNsense NAT reflection routes internally (no round trip to internet). "
|
||||
"Full VLAN isolation preserved. IoT = untrusted = external user."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user