Ports published on 0.0.0.0 were linked at window.location.hostname. Opened
through a reverse proxy such as Nginx Proxy Manager that is the proxy's
domain, and domain:8080 ended up at the proxy instead of the container.
The backend now reads the host's address from the host's routing table
(${HOST_PROC_PATH}/1/net/route + fib_trie, the /proc mount the compose file
already has): the IPv4 address on the interface with the best default
route. There is no fallback to /proc/net, which in a bridged container is
the container's own namespace with an unreachable 172.x address.
The links use, in order: the new "Host address for port links" setting
(Settings -> General), the page's hostname when it is an IP or localhost,
the detected address, and the page's hostname as a last resort. Ports
bound to one specific address still link there; IPv6 hosts are bracketed.
The setting is validated before anything is written, so a bad value no
longer leaves the interval half-saved in the same request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
7.4 KiB
Python
230 lines
7.4 KiB
Python
"""Application settings: update interval, port links + notification webhooks."""
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import re
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from sqlmodel import Session, select
|
|
|
|
from auth import get_current_user, require_admin
|
|
from config import settings as env_settings
|
|
from database import get_session
|
|
from models.setting import (
|
|
ALL_EVENTS,
|
|
WEBHOOK_TYPES,
|
|
SettingsRead,
|
|
SettingsUpdate,
|
|
Webhook,
|
|
WebhookCreate,
|
|
WebhookRead,
|
|
WebhookUpdate,
|
|
)
|
|
from models.user import User
|
|
from services import audit_service, host_address_service, notify_service, settings_service
|
|
|
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
|
|
|
|
|
def _ip(request: Request) -> str:
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
def _to_read(wh: Webhook) -> WebhookRead:
|
|
return WebhookRead(
|
|
id=wh.id,
|
|
name=wh.name,
|
|
url=wh.url,
|
|
type=wh.type,
|
|
events=[e.strip() for e in (wh.events or "").split(",") if e.strip()],
|
|
enabled=wh.enabled,
|
|
created_at=wh.created_at,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# General settings
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
_HOSTNAME = re.compile(r"^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$")
|
|
|
|
|
|
def _clean_link_host(raw: str) -> str:
|
|
"""A bare IP or hostname — no scheme, port or path, since the UI appends
|
|
``:<port>`` itself. IPv6 is stored without brackets."""
|
|
value = raw.strip()
|
|
if not value:
|
|
return ""
|
|
unbracketed = value[1:-1] if value.startswith("[") and value.endswith("]") else value
|
|
try:
|
|
return str(ipaddress.ip_address(unbracketed))
|
|
except ValueError:
|
|
pass
|
|
if _HOSTNAME.match(value):
|
|
return value.lower()
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Port link host must be a bare IP address or hostname (no http://, port or path)",
|
|
)
|
|
|
|
|
|
@router.get("", response_model=SettingsRead)
|
|
def get_settings(
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> SettingsRead:
|
|
return SettingsRead(
|
|
update_check_interval_minutes=settings_service.get_update_interval(session),
|
|
port_link_host=settings_service.get_port_link_host(session),
|
|
detected_host_ip=host_address_service.detect_host_ip(),
|
|
env_webhook_count=len(env_settings.NOTIFY_WEBHOOKS),
|
|
available_events=ALL_EVENTS,
|
|
webhook_types=WEBHOOK_TYPES,
|
|
)
|
|
|
|
|
|
@router.put("", response_model=SettingsRead)
|
|
def update_settings(
|
|
body: SettingsUpdate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> SettingsRead:
|
|
# Validate everything before writing anything: a bad host must not leave
|
|
# the interval half-saved.
|
|
if body.update_check_interval_minutes is not None and body.update_check_interval_minutes < 5:
|
|
raise HTTPException(status_code=400, detail="Interval must be at least 5 minutes")
|
|
link_host = _clean_link_host(body.port_link_host) if body.port_link_host is not None else None
|
|
|
|
if body.update_check_interval_minutes is not None:
|
|
settings_service.set_value(
|
|
session,
|
|
settings_service.KEY_UPDATE_INTERVAL,
|
|
body.update_check_interval_minutes,
|
|
)
|
|
audit_service.record(
|
|
session, user=user.username, action="settings.update",
|
|
target="update_interval", detail=str(body.update_check_interval_minutes),
|
|
ip=_ip(request),
|
|
)
|
|
if link_host is not None:
|
|
settings_service.set_value(session, settings_service.KEY_PORT_LINK_HOST, link_host)
|
|
audit_service.record(
|
|
session, user=user.username, action="settings.update",
|
|
target="port_link_host", detail=link_host or "(detect)",
|
|
ip=_ip(request),
|
|
)
|
|
return get_settings(session, user)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Webhooks
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _validate(wtype: str, events: list[str]) -> None:
|
|
if wtype not in WEBHOOK_TYPES:
|
|
raise HTTPException(status_code=400, detail=f"Unknown webhook type '{wtype}'")
|
|
bad = [e for e in events if e not in ALL_EVENTS]
|
|
if bad:
|
|
raise HTTPException(status_code=400, detail=f"Unknown event(s): {', '.join(bad)}")
|
|
|
|
|
|
@router.get("/webhooks", response_model=list[WebhookRead])
|
|
def list_webhooks(
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(require_admin),
|
|
) -> list[WebhookRead]:
|
|
rows = session.exec(select(Webhook).order_by(Webhook.id)).all()
|
|
return [_to_read(w) for w in rows]
|
|
|
|
|
|
@router.post("/webhooks", response_model=WebhookRead, status_code=201)
|
|
def create_webhook(
|
|
body: WebhookCreate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> WebhookRead:
|
|
_validate(body.type, body.events)
|
|
wh = Webhook(
|
|
name=body.name,
|
|
url=body.url,
|
|
type=body.type,
|
|
events=",".join(body.events),
|
|
enabled=body.enabled,
|
|
)
|
|
session.add(wh)
|
|
session.commit()
|
|
session.refresh(wh)
|
|
audit_service.record(
|
|
session, user=user.username, action="webhook.create", target=str(wh.id),
|
|
detail=body.name, ip=_ip(request),
|
|
)
|
|
return _to_read(wh)
|
|
|
|
|
|
@router.put("/webhooks/{webhook_id}", response_model=WebhookRead)
|
|
def update_webhook(
|
|
webhook_id: int,
|
|
body: WebhookUpdate,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> WebhookRead:
|
|
wh = session.get(Webhook, webhook_id)
|
|
if not wh:
|
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
|
if body.type is not None or body.events is not None:
|
|
_validate(body.type or wh.type, body.events if body.events is not None else _to_read(wh).events)
|
|
if body.name is not None:
|
|
wh.name = body.name
|
|
if body.url is not None:
|
|
wh.url = body.url
|
|
if body.type is not None:
|
|
wh.type = body.type
|
|
if body.events is not None:
|
|
wh.events = ",".join(body.events)
|
|
if body.enabled is not None:
|
|
wh.enabled = body.enabled
|
|
session.add(wh)
|
|
session.commit()
|
|
session.refresh(wh)
|
|
audit_service.record(
|
|
session, user=user.username, action="webhook.update", target=str(wh.id),
|
|
ip=_ip(request),
|
|
)
|
|
return _to_read(wh)
|
|
|
|
|
|
@router.delete("/webhooks/{webhook_id}")
|
|
def delete_webhook(
|
|
webhook_id: int,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
wh = session.get(Webhook, webhook_id)
|
|
if not wh:
|
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
|
session.delete(wh)
|
|
session.commit()
|
|
audit_service.record(
|
|
session, user=user.username, action="webhook.delete", target=str(webhook_id),
|
|
ip=_ip(request),
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/webhooks/{webhook_id}/test")
|
|
async def test_webhook(
|
|
webhook_id: int,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(require_admin),
|
|
) -> dict:
|
|
wh = session.get(Webhook, webhook_id)
|
|
if not wh:
|
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
|
ok = await notify_service.test_webhook(wh.type, wh.url)
|
|
return {"ok": ok}
|