fix(webui): require token_issue_secret for LAN access with frontend auth
When host is set to 0.0.0.0, the gateway now enforces that either token or token_issue_secret must be configured — it refuses to start otherwise. Bootstrap endpoint behavior: - token_issue_secret configured: always validate regardless of source IP (handles reverse-proxy scenarios where all connections appear as localhost) - No secret: only localhost can bootstrap (local dev mode) The frontend shows an authentication form when bootstrap returns 401/403, persists the secret in localStorage, and retries automatically on reload.
This commit is contained in:
+131
-36
@@ -9,14 +9,23 @@ import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { useSessions } from "@/hooks/useSessions";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||
import {
|
||||
clearSavedSecret,
|
||||
deriveWsUrl,
|
||||
fetchBootstrap,
|
||||
loadSavedSecret,
|
||||
saveSecret,
|
||||
} from "@/lib/bootstrap";
|
||||
import { NanobotClient } from "@/lib/nanobot-client";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type BootState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "auth"; failed?: boolean }
|
||||
| {
|
||||
status: "ready";
|
||||
client: NanobotClient;
|
||||
@@ -28,6 +37,60 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||
const SIDEBAR_WIDTH = 272;
|
||||
type ShellView = "chat" | "settings";
|
||||
|
||||
function AuthForm({
|
||||
failed,
|
||||
onSecret,
|
||||
}: {
|
||||
failed: boolean;
|
||||
onSecret: (secret: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const secret = value.trim();
|
||||
if (!secret) return;
|
||||
setSubmitting(true);
|
||||
onSecret(secret);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center px-6">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full max-w-sm flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
|
||||
</div>
|
||||
{failed && (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
{t("app.auth.invalid")}
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("app.auth.placeholder")}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
disabled={submitting}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!value.trim() || submitting}
|
||||
>
|
||||
{t("app.auth.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function readSidebarOpen(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
@@ -43,40 +106,55 @@ export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<BootState>({ status: "loading" });
|
||||
|
||||
const bootstrapWithSecret = useCallback(
|
||||
(secret: string) => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setState({ status: "loading" });
|
||||
try {
|
||||
const boot = await fetchBootstrap("", secret);
|
||||
if (cancelled) return;
|
||||
if (secret) saveSecret(secret);
|
||||
const url = deriveWsUrl(boot.ws_path, boot.token);
|
||||
const client = new NanobotClient({
|
||||
url,
|
||||
onReauth: async () => {
|
||||
try {
|
||||
const refreshed = await fetchBootstrap("", secret);
|
||||
return deriveWsUrl(refreshed.ws_path, refreshed.token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
client.connect();
|
||||
setState({
|
||||
status: "ready",
|
||||
client,
|
||||
token: boot.token,
|
||||
modelName: boot.model_name ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
|
||||
setState({ status: "auth", failed: true });
|
||||
} else {
|
||||
setState({ status: "error", message: msg });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const boot = await fetchBootstrap();
|
||||
if (cancelled) return;
|
||||
const url = deriveWsUrl(boot.ws_path, boot.token);
|
||||
const client = new NanobotClient({
|
||||
url,
|
||||
onReauth: async () => {
|
||||
try {
|
||||
const refreshed = await fetchBootstrap();
|
||||
return deriveWsUrl(refreshed.ws_path, refreshed.token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
client.connect();
|
||||
setState({
|
||||
status: "ready",
|
||||
client,
|
||||
token: boot.token,
|
||||
modelName: boot.model_name ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setState({ status: "error", message: (e as Error).message });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const saved = loadSavedSecret();
|
||||
return bootstrapWithSecret(saved);
|
||||
}, [bootstrapWithSecret]);
|
||||
|
||||
useEffect(() => {
|
||||
const warm = () => preloadMarkdownText();
|
||||
@@ -110,6 +188,14 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.status === "auth") {
|
||||
return (
|
||||
<AuthForm
|
||||
failed={!!state.failed}
|
||||
onSecret={(s) => bootstrapWithSecret(s)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center px-4 text-center">
|
||||
@@ -130,18 +216,26 @@ export default function App() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
if (state.status === "ready") {
|
||||
state.client.close();
|
||||
}
|
||||
clearSavedSecret();
|
||||
setState({ status: "auth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<ClientProvider
|
||||
client={state.client}
|
||||
token={state.token}
|
||||
modelName={state.modelName}
|
||||
>
|
||||
<Shell onModelNameChange={handleModelNameChange} />
|
||||
<Shell onModelNameChange={handleModelNameChange} onLogout={handleLogout} />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) {
|
||||
function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
|
||||
@@ -319,6 +413,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={() => setView("chat")}
|
||||
onModelNameChange={onModelNameChange}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
) : (
|
||||
<ThreadShell
|
||||
|
||||
Reference in New Issue
Block a user