🚧 WIP
This commit is contained in:
@@ -38,10 +38,13 @@ def _listener_block(listener: ListenerConfig, warnings: list[str]) -> list[str]:
|
|||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
if not listener.enabled:
|
if not listener.enabled:
|
||||||
return lines
|
return lines
|
||||||
lines.append(f"listener {listener.port} {listener.bind_address or ''}".rstrip())
|
# Always emit an EXPLICIT bind address. Mosquitto's reload compares
|
||||||
if listener.bind_address is None:
|
# listeners textually; "listener 1883" vs "listener 1883 0.0.0.0" are
|
||||||
# `listener <port>` without bind address binds all interfaces.
|
# treated as different listeners, and reload CANNOT add/remove
|
||||||
lines[-1] = f"listener {listener.port}"
|
# listeners (spec section 28). Matching the bootstrap's explicit
|
||||||
|
# form keeps the management listener reloadable.
|
||||||
|
bind = listener.bind_address or "0.0.0.0"
|
||||||
|
lines.append(f"listener {listener.port} {bind}")
|
||||||
lines.append(f"protocol {listener.protocol}")
|
lines.append(f"protocol {listener.protocol}")
|
||||||
if listener.allow_anonymous:
|
if listener.allow_anonymous:
|
||||||
lines.append("listener_allow_anonymous true")
|
lines.append("listener_allow_anonymous true")
|
||||||
@@ -104,11 +107,17 @@ def generate_config(db: Session) -> GenerationResult:
|
|||||||
lines.extend(block)
|
lines.extend(block)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
else:
|
else:
|
||||||
|
# Empty model is a legitimate UI state (fresh platform): generate a
|
||||||
|
# SAFE config instead of raising, so /config/preview works. With no
|
||||||
|
# listener directives Mosquitto binds loopback-only (127.0.0.1/::1)
|
||||||
|
# on port 1883 with anonymous access — reachable only inside the
|
||||||
|
# container network. The warning explains exactly what will happen.
|
||||||
warnings.append(
|
warnings.append(
|
||||||
"no enabled listeners; broker would fall back to loopback-only "
|
"No enabled listeners in the configuration model. The generated "
|
||||||
"with anonymous access — refusing to generate"
|
"config contains no listener directives, so Mosquitto will bind "
|
||||||
|
"loopback-only (127.0.0.1/::1) on port 1883 with anonymous access "
|
||||||
|
"— internal to the container network only. Add a listener below."
|
||||||
)
|
)
|
||||||
raise ValueError("no enabled listeners in configuration model")
|
|
||||||
|
|
||||||
lines.append("# --- Persistence ---")
|
lines.append("# --- Persistence ---")
|
||||||
lines.append("persistence true")
|
lines.append("persistence true")
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
BrokerSettingsIn,
|
BrokerSettingsIn,
|
||||||
|
BrokerSettingsOut,
|
||||||
ConfigPreviewOut,
|
ConfigPreviewOut,
|
||||||
DeployRequest,
|
DeployRequest,
|
||||||
ListenerIn,
|
ListenerIn,
|
||||||
@@ -126,13 +127,21 @@ def update_listener(
|
|||||||
for key, value in body.model_dump().items():
|
for key, value in body.model_dump().items():
|
||||||
setattr(listener, key, value)
|
setattr(listener, key, value)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
# Audit the security-relevant listener fields (spec section 38): the
|
||||||
|
# full PUT payload is a replacement, so record what it now says.
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
"LISTENER_UPDATED",
|
"LISTENER_UPDATED",
|
||||||
request=request,
|
request=request,
|
||||||
object_type="listener",
|
object_type="listener",
|
||||||
object_id=str(listener.id),
|
object_id=str(listener.id),
|
||||||
new_value={"name": body.name, "port": body.port},
|
new_value={
|
||||||
|
"name": body.name,
|
||||||
|
"port": body.port,
|
||||||
|
"bind_address": body.bind_address,
|
||||||
|
"protocol": body.protocol,
|
||||||
|
"allow_anonymous": body.allow_anonymous,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return listener
|
return listener
|
||||||
|
|
||||||
@@ -161,7 +170,7 @@ def delete_listener(
|
|||||||
# --- Global settings -----------------------------------------------------------
|
# --- Global settings -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings", response_model=list[BrokerSettingsIn])
|
@router.get("/settings", response_model=list[BrokerSettingsOut])
|
||||||
def list_settings(db: Annotated[Session, Depends(get_db)], _user: AnyReader):
|
def list_settings(db: Annotated[Session, Depends(get_db)], _user: AnyReader):
|
||||||
return db.scalars(select(BrokerSettings)).all()
|
return db.scalars(select(BrokerSettings)).all()
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,12 @@ class BrokerSettingsIn(BaseModel):
|
|||||||
value_type: Literal["string", "int", "bool"] = "string"
|
value_type: Literal["string", "int", "bool"] = "string"
|
||||||
|
|
||||||
|
|
||||||
|
class BrokerSettingsOut(BrokerSettingsIn):
|
||||||
|
"""GET /broker/settings rows — same fields, from ORM attributes."""
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
class ListenerIn(BaseModel):
|
class ListenerIn(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=100)
|
name: str = Field(min_length=1, max_length=100)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|||||||
@@ -180,6 +180,37 @@ def test_listener_management_and_preview(admin_client):
|
|||||||
assert delete.status_code == 204
|
assert delete.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_listener_update_and_preview(admin_client):
|
||||||
|
listener_id = admin_client.post(
|
||||||
|
"/api/v1/broker/listeners",
|
||||||
|
json={"name": "Production MQTT", "port": 1883, "protocol": "mqtt"},
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
updated = admin_client.put(
|
||||||
|
f"/api/v1/broker/listeners/{listener_id}",
|
||||||
|
json={
|
||||||
|
"name": "Production MQTT",
|
||||||
|
"port": 1884,
|
||||||
|
"bind_address": "127.0.0.1",
|
||||||
|
"protocol": "websockets",
|
||||||
|
"allow_anonymous": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200, updated.text
|
||||||
|
body = updated.json()
|
||||||
|
assert body["port"] == 1884
|
||||||
|
assert body["bind_address"] == "127.0.0.1"
|
||||||
|
assert body["protocol"] == "websockets"
|
||||||
|
assert body["allow_anonymous"] is True
|
||||||
|
|
||||||
|
config_text = admin_client.get("/api/v1/broker/config/preview").json()[
|
||||||
|
"config_text"
|
||||||
|
]
|
||||||
|
assert "listener 1884 127.0.0.1" in config_text
|
||||||
|
assert "protocol websockets" in config_text
|
||||||
|
assert "listener_allow_anonymous true" in config_text
|
||||||
|
|
||||||
|
|
||||||
def test_audit_events_recorded(admin_client, db_session):
|
def test_audit_events_recorded(admin_client, db_session):
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,12 @@ def test_generate_includes_global_settings(session):
|
|||||||
assert "max_packet_size 2000000" in result.config_text
|
assert "max_packet_size 2000000" in result.config_text
|
||||||
|
|
||||||
|
|
||||||
def test_generate_refuses_without_listeners(session):
|
def test_generate_with_empty_model_warns_instead_of_raising(session):
|
||||||
with pytest.raises(ValueError, match="no enabled listeners"):
|
"""Fresh-platform state: preview must work, not 500 (config page fix)."""
|
||||||
generate_config(session)
|
result = generate_config(session)
|
||||||
|
assert "global_plugin /usr/lib/mosquitto_dynamic_security.so" in result.config_text
|
||||||
|
assert "persistence true" in result.config_text
|
||||||
|
assert any("No enabled listeners" in w for w in result.warnings)
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_listeners_are_omitted(session):
|
def test_disabled_listeners_are_omitted(session):
|
||||||
|
|||||||
@@ -130,13 +130,18 @@ def _post_broker_health() -> bool:
|
|||||||
Uses mosquitto_ctrl rather than a $SYS subscription, because the 2.1
|
Uses mosquitto_ctrl rather than a $SYS subscription, because the 2.1
|
||||||
auto-generated admin role set is not guaranteed to include $SYS read
|
auto-generated admin role set is not guaranteed to include $SYS read
|
||||||
access — but it must always be able to administer dynamic security.
|
access — but it must always be able to administer dynamic security.
|
||||||
|
|
||||||
|
NOTE: this sidecar shares the broker's PID namespace but NOT its
|
||||||
|
network namespace — 127.0.0.1 here is the SIDECAR's loopback, where
|
||||||
|
nothing listens. The broker is reached via Docker DNS ("mosquitto")
|
||||||
|
on the backend-net network.
|
||||||
"""
|
"""
|
||||||
username = os.environ.get("MOSQUITTO_DYNSEC_USERNAME", "admin")
|
username = os.environ.get("MOSQUITTO_DYNSEC_USERNAME", "admin")
|
||||||
token_pw = os.environ.get("MOSQUITTO_DYNSEC_PASSWORD", "")
|
token_pw = os.environ.get("MOSQUITTO_DYNSEC_PASSWORD", "")
|
||||||
cmd = [
|
cmd = [
|
||||||
"mosquitto_ctrl",
|
"mosquitto_ctrl",
|
||||||
"-h",
|
"-h",
|
||||||
"127.0.0.1",
|
"mosquitto",
|
||||||
"-u",
|
"-u",
|
||||||
username,
|
username,
|
||||||
"-P",
|
"-P",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ x-backend-env: &backend-env
|
|||||||
MOSQUITTO_MGMT_PORT: ${MOSQUITTO_MGMT_PORT:-1883}
|
MOSQUITTO_MGMT_PORT: ${MOSQUITTO_MGMT_PORT:-1883}
|
||||||
MOSQUITTO_DYNSEC_USERNAME: ${MOSQUITTO_DYNSEC_USERNAME:-admin}
|
MOSQUITTO_DYNSEC_USERNAME: ${MOSQUITTO_DYNSEC_USERNAME:-admin}
|
||||||
MOSQUITTO_DYNSEC_PASSWORD: ${MOSQUITTO_DYNSEC_PASSWORD:?MOSQUITTO_DYNSEC_PASSWORD must be set}
|
MOSQUITTO_DYNSEC_PASSWORD: ${MOSQUITTO_DYNSEC_PASSWORD:?MOSQUITTO_DYNSEC_PASSWORD must be set}
|
||||||
|
CONTROL_API_TOKEN: ${CONTROL_API_TOKEN:?CONTROL_API_TOKEN must be set}
|
||||||
DEFAULT_TLS_MIN_VERSION: ${DEFAULT_TLS_MIN_VERSION:-tlsv1.2}
|
DEFAULT_TLS_MIN_VERSION: ${DEFAULT_TLS_MIN_VERSION:-tlsv1.2}
|
||||||
DEFAULT_CERT_VALIDITY_DAYS: ${DEFAULT_CERT_VALIDITY_DAYS:-365}
|
DEFAULT_CERT_VALIDITY_DAYS: ${DEFAULT_CERT_VALIDITY_DAYS:-365}
|
||||||
DEFAULT_CA_VALIDITY_DAYS: ${DEFAULT_CA_VALIDITY_DAYS:-3650}
|
DEFAULT_CA_VALIDITY_DAYS: ${DEFAULT_CA_VALIDITY_DAYS:-3650}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
|
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "./auth";
|
import { useAuth } from "./auth";
|
||||||
import { LoginPage } from "./pages/Login";
|
import { LoginPage } from "./pages/Login";
|
||||||
import { ClientsPage } from "./pages/Clients";
|
import { ClientsPage } from "./pages/Clients";
|
||||||
@@ -30,8 +30,12 @@ function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function Protected({ children }: { children: React.ReactNode }) {
|
function Protected({ children }: { children: React.ReactNode }) {
|
||||||
const { session, loading } = useAuth();
|
const { session, loading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
if (loading) return <p>Loading…</p>;
|
if (loading) return <p>Loading…</p>;
|
||||||
if (!session) return <Navigate to="/login" replace />;
|
if (!session) {
|
||||||
|
// Remember the guarded route so Login can return here after sign-in.
|
||||||
|
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||||
|
}
|
||||||
return <Layout>{children}</Layout>;
|
return <Layout>{children}</Layout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ function errorMessage(body: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A 401 here means the session cookie expired or was never valid.
|
||||||
|
// api.ts lives outside the React router, so the only way to the login
|
||||||
|
// screen is a full-page navigation; the current page is remembered via
|
||||||
|
// ?next= so Login can return after sign-in.
|
||||||
|
// Excluded: the login attempt itself (a 401 there is bad credentials and
|
||||||
|
// must render the error banner, not navigate away), the startup session
|
||||||
|
// probe (a 401 there is the normal "not signed in" state, which
|
||||||
|
// <Protected> turns into an in-app redirect instead), and logout (a 401
|
||||||
|
// there just means the session already expired — auth.tsx clears the
|
||||||
|
// client session anyway, so no redirect is needed).
|
||||||
|
const EXPECTED_401_PATHS = new Set(["/auth/login", "/auth/session", "/auth/logout"]);
|
||||||
|
|
||||||
|
function redirectToLogin(): void {
|
||||||
|
const current = window.location.pathname;
|
||||||
|
if (current === "/login") return; // defensive: never reload the login page
|
||||||
|
window.location.replace(`/login?next=${encodeURIComponent(current)}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${BASE}${path}`, {
|
const response = await fetch(`${BASE}${path}`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -55,6 +73,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
} catch {
|
} catch {
|
||||||
/* non-JSON error body */
|
/* non-JSON error body */
|
||||||
}
|
}
|
||||||
|
if (response.status === 401 && !EXPECTED_401_PATHS.has(path)) {
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
throw new ApiError(response.status, message);
|
throw new ApiError(response.status, message);
|
||||||
}
|
}
|
||||||
if (response.status === 204) return undefined as T;
|
if (response.status === 204) return undefined as T;
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await api.post("/auth/logout");
|
// A 401 here means the session already expired server-side; clearing
|
||||||
|
// the client session is still the right outcome (api.ts does not
|
||||||
|
// redirect for /auth/logout), and <Protected> then shows /login.
|
||||||
|
await api.post("/auth/logout").catch(() => undefined);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, type Listener, type Revision } from "../api";
|
import { api, type Listener, type Revision } from "../api";
|
||||||
|
|
||||||
interface ConfigPreview {
|
interface ConfigPreview {
|
||||||
@@ -6,22 +7,80 @@ interface ConfigPreview {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BrokerSetting {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
value_type: "string" | "int" | "bool";
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_LISTENER: Omit<Listener, "id"> = {
|
||||||
|
name: "",
|
||||||
|
enabled: true,
|
||||||
|
port: 1883,
|
||||||
|
bind_address: null,
|
||||||
|
protocol: "mqtt",
|
||||||
|
allow_anonymous: false,
|
||||||
|
max_connections: -1,
|
||||||
|
tls_enabled: false,
|
||||||
|
tls_min_version: null,
|
||||||
|
require_certificate: false,
|
||||||
|
use_identity_as_username: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Curated subset of spec section 31; anything else can be added later
|
||||||
|
// through the same settings endpoint (advanced editor is a later release).
|
||||||
|
const COMMON_SETTINGS: { key: string; label: string; placeholder: string; hint: string }[] = [
|
||||||
|
{ key: "max_packet_size", label: "max_packet_size", placeholder: "2000000", hint: "bytes; 2.1 default is 2 MB" },
|
||||||
|
{ key: "autosave_interval", label: "autosave_interval", placeholder: "900", hint: "seconds between persistence saves" },
|
||||||
|
{ key: "sys_interval", label: "sys_interval", placeholder: "10", hint: "$SYS update interval (s)" },
|
||||||
|
{ key: "message_size_limit", label: "message_size_limit", placeholder: "0", hint: "max payload bytes; 0 = unlimited" },
|
||||||
|
{ key: "max_queued_messages", label: "max_queued_messages", placeholder: "1000", hint: "per-client queue for offline clients" },
|
||||||
|
{ key: "global_max_clients", label: "global_max_clients", placeholder: "-1", hint: "total sessions; -1 = unlimited" },
|
||||||
|
];
|
||||||
|
|
||||||
export function BrokerConfigPage() {
|
export function BrokerConfigPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const refresh = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["listeners"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["config-preview"] });
|
||||||
|
};
|
||||||
|
|
||||||
const listeners = useQuery({
|
const listeners = useQuery({
|
||||||
queryKey: ["listeners"],
|
queryKey: ["listeners"],
|
||||||
queryFn: () => api.get<Listener[]>("/broker/listeners"),
|
queryFn: () => api.get<Listener[]>("/broker/listeners"),
|
||||||
});
|
});
|
||||||
|
const settings = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: () => api.get<BrokerSetting[]>("/broker/settings"),
|
||||||
|
});
|
||||||
const preview = useQuery({
|
const preview = useQuery({
|
||||||
queryKey: ["config-preview"],
|
queryKey: ["config-preview"],
|
||||||
queryFn: () => api.get<ConfigPreview>("/broker/config/preview"),
|
queryFn: () => api.get<ConfigPreview>("/broker/config/preview"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const revisions = useQuery({
|
const revisions = useQuery({
|
||||||
queryKey: ["revisions"],
|
queryKey: ["revisions"],
|
||||||
queryFn: () => api.get<Revision[]>("/broker/revisions"),
|
queryFn: () => api.get<Revision[]>("/broker/revisions"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const addListener = useMutation({
|
||||||
|
mutationFn: (l: Omit<Listener, "id">) => api.post<Listener>("/broker/listeners", l),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const updateListener = useMutation({
|
||||||
|
mutationFn: ({ id, ...l }: Listener) => api.put<Listener>(`/broker/listeners/${id}`, l),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const removeListener = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/broker/listeners/${id}`),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const putSetting = useMutation({
|
||||||
|
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||||
|
api.put(`/broker/settings/${key}`, { key, value, value_type: "string" }),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
const deploy = useMutation({
|
const deploy = useMutation({
|
||||||
mutationFn: (comment: string | null) =>
|
mutationFn: (comment: string | null) =>
|
||||||
api.post<{ status: string; revision: number; broker_healthy: boolean }>(
|
api.post<{ status: string; revision: number; broker_healthy: boolean }>(
|
||||||
@@ -29,8 +88,8 @@ export function BrokerConfigPage() {
|
|||||||
{ comment },
|
{ comment },
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
preview.refetch();
|
refresh();
|
||||||
revisions.refetch();
|
queryClient.invalidateQueries({ queryKey: ["revisions"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,15 +97,23 @@ export function BrokerConfigPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2>Broker configuration</h2>
|
<h2>Broker configuration</h2>
|
||||||
<p>
|
<p>
|
||||||
The configuration is generated from the platform model, validated with
|
The configuration is generated from the model below, validated with{" "}
|
||||||
<code> mosquitto --test-config</code>, deployed atomically, and every
|
<code>mosquitto --test-config</code>, deployed atomically, and every
|
||||||
deployment creates a revision.
|
deployment creates a revision.
|
||||||
</p>
|
</p>
|
||||||
|
{(listeners.error || preview.error || settings.error) && (
|
||||||
|
<div className="banner error">
|
||||||
|
{((listeners.error ?? preview.error ?? settings.error) as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<h3>Listeners</h3>
|
<h3 style={{ marginTop: "1.5rem" }}>Add listener</h3>
|
||||||
{listeners.isLoading ? (
|
<ListenerForm onSubmit={(l) => addListener.mutate(l)} pending={addListener.isPending} />
|
||||||
<p>Loading…</p>
|
{addListener.error && (
|
||||||
) : (
|
<div className="banner error">{(addListener.error as Error).message}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: "2rem" }}>Listeners</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -56,10 +123,26 @@ export function BrokerConfigPage() {
|
|||||||
<th>Protocol</th>
|
<th>Protocol</th>
|
||||||
<th>Anonymous</th>
|
<th>Anonymous</th>
|
||||||
<th>Enabled</th>
|
<th>Enabled</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(listeners.data ?? []).map((l) => (
|
{(listeners.data ?? []).map((l) =>
|
||||||
|
editingId === l.id ? (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td colSpan={7}>
|
||||||
|
<ListenerForm
|
||||||
|
initial={l}
|
||||||
|
pending={updateListener.isPending}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
onSubmit={(patch) => {
|
||||||
|
updateListener.mutate({ ...l, ...patch });
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
<tr key={l.id}>
|
<tr key={l.id}>
|
||||||
<td>{l.name}</td>
|
<td>{l.name}</td>
|
||||||
<td className="mono">{l.port}</td>
|
<td className="mono">{l.port}</td>
|
||||||
@@ -67,12 +150,59 @@ export function BrokerConfigPage() {
|
|||||||
<td>{l.protocol}</td>
|
<td>{l.protocol}</td>
|
||||||
<td>{l.allow_anonymous ? "yes" : "no"}</td>
|
<td>{l.allow_anonymous ? "yes" : "no"}</td>
|
||||||
<td>{l.enabled ? "yes" : "no"}</td>
|
<td>{l.enabled ? "yes" : "no"}</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => setEditingId(l.id)}>Edit</button>{" "}
|
||||||
|
<button onClick={() => updateListener.mutate({ ...l, enabled: !l.enabled })}>
|
||||||
|
{l.enabled ? "Disable" : "Enable"}
|
||||||
|
</button>{" "}
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete listener ${l.name}?`)) removeListener.mutate(l.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{(listeners.data ?? []).length === 0 && !listeners.isLoading && (
|
||||||
|
<p style={{ color: "#52606d" }}>
|
||||||
|
No listeners yet. Add one above — after a deploy, the broker is only
|
||||||
|
reachable through the listeners defined here.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: "2rem" }}>Global settings</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Setting</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{COMMON_SETTINGS.map((s) => {
|
||||||
|
const existing = (settings.data ?? []).find((row) => row.key === s.key);
|
||||||
|
return (
|
||||||
|
<SettingsRow
|
||||||
|
key={s.key}
|
||||||
|
label={s.label}
|
||||||
|
hint={s.hint}
|
||||||
|
placeholder={s.placeholder}
|
||||||
|
initial={existing?.value}
|
||||||
|
pending={putSetting.isPending}
|
||||||
|
onSave={(value) => putSetting.mutate({ key: s.key, value })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
<h3 style={{ marginTop: "2rem" }}>Generated configuration</h3>
|
<h3 style={{ marginTop: "2rem" }}>Generated configuration</h3>
|
||||||
{preview.data?.warnings.map((w) => (
|
{preview.data?.warnings.map((w) => (
|
||||||
<div key={w} className="banner warn">
|
<div key={w} className="banner warn">
|
||||||
@@ -127,3 +257,147 @@ export function BrokerConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ListenerForm({
|
||||||
|
initial,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
initial?: Listener;
|
||||||
|
onSubmit: (l: Omit<Listener, "id">) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<Omit<Listener, "id">>(initial ?? EMPTY_LISTENER);
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(initial ?? EMPTY_LISTENER);
|
||||||
|
}, [initial]);
|
||||||
|
const set = (patch: Partial<Listener>) => setDraft((d) => ({ ...d, ...patch }));
|
||||||
|
const valid = draft.name.trim() !== "" && draft.port > 0 && draft.port <= 65535;
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (valid) {
|
||||||
|
onSubmit(draft);
|
||||||
|
if (!initial) setDraft(EMPTY_LISTENER);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="stack" onSubmit={submit}>
|
||||||
|
{initial && (
|
||||||
|
<div className="banner warn">
|
||||||
|
Port or bind changes require a broker restart — a config reload
|
||||||
|
cannot modify existing listener definitions.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||||
|
<div>
|
||||||
|
<label>Name</label>
|
||||||
|
<input
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => set({ name: e.target.value })}
|
||||||
|
placeholder="Production MQTT"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={draft.port}
|
||||||
|
onChange={(e) => set({ port: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Protocol</label>
|
||||||
|
<select
|
||||||
|
value={draft.protocol}
|
||||||
|
onChange={(e) => set({ protocol: e.target.value as "mqtt" | "websockets" })}
|
||||||
|
>
|
||||||
|
<option value="mqtt">mqtt</option>
|
||||||
|
<option value="websockets">websockets</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Bind address</label>
|
||||||
|
<input
|
||||||
|
value={draft.bind_address ?? ""}
|
||||||
|
onChange={(e) => set({ bind_address: e.target.value || null })}
|
||||||
|
placeholder="all interfaces"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Allow anonymous</label>
|
||||||
|
<select
|
||||||
|
value={draft.allow_anonymous ? "true" : "false"}
|
||||||
|
onChange={(e) => set({ allow_anonymous: e.target.value === "true" })}
|
||||||
|
>
|
||||||
|
<option value="false">false</option>
|
||||||
|
<option value="true">true</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Max connections</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={draft.max_connections}
|
||||||
|
onChange={(e) => set({ max_connections: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p />
|
||||||
|
<button className="primary" disabled={!valid || pending}>
|
||||||
|
{pending ? (initial ? "Saving…" : "Adding…") : initial ? "Save changes" : "Add listener"}
|
||||||
|
</button>{" "}
|
||||||
|
{initial && onCancel && (
|
||||||
|
<button type="button" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsRow({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
placeholder,
|
||||||
|
initial,
|
||||||
|
pending,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
placeholder: string;
|
||||||
|
initial?: string;
|
||||||
|
pending: boolean;
|
||||||
|
onSave: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(initial ?? "");
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(initial ?? "");
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span className="mono">{label}</span>
|
||||||
|
<div style={{ color: "#52606d", fontSize: "0.75rem" }}>{hint}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
style={{ maxWidth: "220px" }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button disabled={pending || value === (initial ?? "")} onClick={() => onSave(value)}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,41 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
|
|
||||||
|
// Only same-app targets are allowed: blocks open redirects such as
|
||||||
|
// "//evil.host" or "https://evil.host" via the ?next= query parameter.
|
||||||
|
function safeTarget(candidate: string | null): string {
|
||||||
|
return candidate && candidate.startsWith("/") && !candidate.startsWith("//")
|
||||||
|
? candidate
|
||||||
|
: "/clients";
|
||||||
|
}
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Where to go after sign-in. <Protected> passes the guarded route via
|
||||||
|
// router state (in-app navigation); the 401 handler in api.ts passes the
|
||||||
|
// current page as ?next= (full-page navigation). Direct /login visits
|
||||||
|
// default to /clients.
|
||||||
|
const from = safeTarget(
|
||||||
|
(location.state as { from?: string } | null)?.from ?? searchParams.get("next"),
|
||||||
|
);
|
||||||
|
|
||||||
const submit = async (e: FormEvent) => {
|
const submit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
|
// replace: Back must not return to the login screen after sign-in.
|
||||||
|
navigate(from, { replace: true });
|
||||||
} catch {
|
} catch {
|
||||||
setError("Invalid username or password.");
|
setError("Invalid username or password.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
# Plaintext is acceptable here because the network is isolated and the
|
# Plaintext is acceptable here because the network is isolated and the
|
||||||
# listener is not published to the host. Production TLS listeners are
|
# listener is not published to the host. Production TLS listeners are
|
||||||
# added via the platform (spec §60).
|
# added via the platform (spec §60).
|
||||||
|
# NOTE: the platform generator always emits explicit bind addresses for
|
||||||
|
# listeners — Mosquitto's reload compares listeners textually and refuses
|
||||||
|
# to add/remove listeners on reload (spec §28). This line matches that
|
||||||
|
# form so the first platform deploy is a pure reload, not a restart.
|
||||||
listener 1883 0.0.0.0
|
listener 1883 0.0.0.0
|
||||||
allow_anonymous false
|
allow_anonymous false
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user