This commit is contained in:
Jonas Bengtsson
2026-09-19 09:40:24 +02:00
parent ee4800993d
commit 19bccce083
13 changed files with 435 additions and 43 deletions
+16 -7
View File
@@ -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")
+11 -2
View File
@@ -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()
+6
View File
@@ -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
+31
View File
@@ -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
+6 -3
View File
@@ -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):
+6 -1
View File
@@ -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",
+1
View File
@@ -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}
+6 -2
View File
@@ -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>;
} }
+21
View File
@@ -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;
+4 -1
View File
@@ -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);
}, []); }, []);
+286 -12
View File
@@ -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>
);
}
+22
View File
@@ -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 {
+4
View File
@@ -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