Add TLS feature with Caddy

This commit is contained in:
2026-10-21 09:13:59 +01:00
parent 1462067e72
commit ed4a6f3ec8
109 changed files with 6374 additions and 156 deletions
@@ -1,8 +1,9 @@
import { Activity, Shield, Wrench } from "lucide-react";
import { Activity, LockKeyhole, Shield, Wrench } from "lucide-react";
import type { SubNavItemType } from "@/src/components/general/sub-nav/sub-nav-item";
export const SetupNav: SubNavItemType[] = [
{ title: "System status", href: "/admin/setup/status", Icon: Activity },
{ title: "Maintenance", href: "/admin/setup/maintenance", Icon: Wrench },
{ title: "Firewall", href: "/admin/setup/firewall", Icon: Shield },
{ title: "TLS ingress", href: "/admin/setup/tls", Icon: LockKeyhole },
];
@@ -0,0 +1,63 @@
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
import type grabTlsStatus from "@/src/functions/backend/acme/grab-tls-status";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useEffect, useState } from "react";
export type TlsStatus = Awaited<ReturnType<typeof grabTlsStatus>>;
type TlsStatusResponse = APIResponseObject & {
singleRes?: TlsStatus | null;
};
export default function useTlsStatus() {
const [tls_status, setTlsStatus] = useState<TlsStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = () =>
fetchApi<ApiReqParams, TlsStatusResponse>(`/api/admin/tls/status`, {
method: "GET",
}).then((res) => {
if (res.success && res.singleRes) {
setTlsStatus(res.singleRes);
setError(null);
} else {
setError(res.msg || `Could not fetch TLS status`);
}
return res;
});
const refreshWithLoading = () => {
setLoading(true);
refresh()
.catch((request_error: any) => {
setError(
request_error?.message || `Could not fetch TLS status`,
);
})
.finally(() => setLoading(false));
};
const sync = () =>
fetchApi<ApiReqParams, APIResponseObject>(`/api/admin/tls/sync`, {
method: "POST",
}).then(async (res) => {
await refresh();
return res;
});
useEffect(() => {
refreshWithLoading();
}, []);
return {
tls_status,
refresh: refreshWithLoading,
sync,
loading,
error,
};
}
@@ -0,0 +1,36 @@
import { ServerCog } from "lucide-react";
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import SetupActionRow from "./setup-action-row";
type Props = {
installed?: boolean | null;
on_done?: () => void;
};
export default function SetupCaddyAction({ installed, on_done }: Props) {
return (
<SetupActionRow
title="Caddy TLS ingress"
description="Installs or repairs the Caddy binary and the wgui-caddy service. Certificate issuance is handled by wg-ui with manual DNS-01. Safe to re-run."
icon={<ServerCog size={15} />}
button_title={installed ? "Repair Caddy" : "Install Caddy"}
confirm_params={{
title: installed ? "Repair the Caddy ingress?" : "Install Caddy?",
message:
"This installs Caddy and the wgui-caddy service. Existing routes and certificates are preserved.",
confirm_label: installed ? "Repair Caddy" : "Install Caddy",
}}
on_run={async () => {
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/install-caddy`,
{ method: "POST" },
);
on_done?.();
return res;
}}
/>
);
}
@@ -5,6 +5,7 @@ import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type useSystemSetupStatus from "../(hooks)/use-system-setup-status";
import SetupActionRow from "../(partials)/setup-action-row";
import SetupCaddyAction from "../(partials)/setup-caddy-action";
import SetupUpdateWgUiAction from "../(partials)/setup-update-wg-ui-action";
type Props = {
@@ -24,6 +25,11 @@ export default function SetupActionsSection({ setup }: Props) {
.filter((tool) => WG_TOOL_COMMANDS.includes(tool.command))
.every((tool) => tool.installed);
const have_caddy = Boolean(
setup_status.tools.find((tool) => tool.command == "/opt/caddy/caddy")
?.installed,
);
const show_restart = !setup_status.is_dev && setup_status.init_system !== "unknown";
return (
@@ -43,6 +49,11 @@ export default function SetupActionsSection({ setup }: Props) {
}}
/>
<SetupCaddyAction
installed={have_caddy}
on_done={() => setup.refresh()}
/>
<SetupUpdateWgUiAction setup={setup} />
{show_restart ? (
@@ -12,7 +12,7 @@ type Props = PropsWithChildren & {
is_root?: boolean;
};
/** Header, sub-navigation and body shared by the three setup subpages. */
/** Header, sub-navigation and body shared by the setup subpages. */
export default function SetupPageFrame({ title, description, is_root, children }: Props) {
return (
<>
@@ -0,0 +1,18 @@
import Panel from "@/src/components/general/panel";
import type useTlsStatus from "../../(hooks)/use-tls-status";
import SetupCaddyAction from "../../(partials)/setup-caddy-action";
type Props = {
tls: ReturnType<typeof useTlsStatus>;
};
export default function TlsInstallSection({ tls }: Props) {
return (
<Panel flush>
<SetupCaddyAction
installed={tls.tls_status?.caddy.installed}
on_done={tls.refresh}
/>
</Panel>
);
}
@@ -0,0 +1,59 @@
import { ArrowRight } from "lucide-react";
import Button from "@/src/components/twui/layout/Button";
import Stack from "@/src/components/twui/layout/Stack";
import P from "@/src/components/twui/layout/P";
import Panel from "@/src/components/general/panel";
import InlineAlert from "@/src/components/general/inline-alert";
import MonoText from "@/src/components/general/mono-text";
import type useTlsStatus from "../../(hooks)/use-tls-status";
type Props = {
tls: ReturnType<typeof useTlsStatus>;
};
export default function TlsSetupSection({ tls }: Props) {
const caddy = tls.tls_status?.caddy;
return (
<Panel
title="Route certificates"
description="Certificates are issued per route with manual DNS-01. No DNS provider token is stored."
actions={
<Button
title="Open routes"
href="/admin/routes"
size="small"
afterIcon={<ArrowRight size={14} />}
>
Open routes
</Button>
}
>
<Stack className="gap-4 items-stretch">
{!caddy ? (
<InlineAlert tone="info" msg="Loading TLS ingress status…" />
) : !caddy.installed ? (
<InlineAlert
tone="warning"
msg="Caddy is not installed. Use Install Caddy above to install the ingress."
/>
) : null}
<P className="text-[15px]!">
Open a route to issue or renew its certificate. The dashboard
displays the exact <MonoText>_acme-challenge</MonoText> TXT record;
add it at Cloudflare, wait for propagation, then click
Validate.
</P>
<P className="text-[15px]!">
Certificates are renewed manually from the route page because
DNS validation needs your registrar. The route list shows
expiry warnings.
</P>
<P className="text-[15px]!">
The Caddy ingress listens on port {caddy?.https_port || 8443}.
It never binds ports 80 or 443.
</P>
</Stack>
</Panel>
);
}
@@ -0,0 +1,117 @@
import { RefreshCw } from "lucide-react";
import Button from "@/src/components/twui/layout/Button";
import Stack from "@/src/components/twui/layout/Stack";
import Panel from "@/src/components/general/panel";
import StatusDot from "@/src/components/general/status-dot";
import InlineAlert from "@/src/components/general/inline-alert";
import KeyValueList from "@/src/components/general/key-value-list";
import type useTlsStatus from "../../(hooks)/use-tls-status";
type Props = {
tls: ReturnType<typeof useTlsStatus>;
};
export default function TlsStatusSection({ tls }: Props) {
const { tls_status, refresh, sync, loading, error } = tls;
const caddy = tls_status?.caddy;
return (
<Panel
title="Ingress"
description="Caddy runs as its own service and terminates TLS on the configured high port."
actions={
<>
<StatusDot
tone={
!caddy?.installed
? "error"
: caddy.service_active === false
? "warning"
: caddy.state?.last_error
? "warning"
: "success"
}
label={
!caddy?.installed
? "Caddy not installed"
: caddy.service_active === false
? "wgui-caddy stopped"
: caddy.version || "Caddy installed"
}
/>
<Button
title="Sync Caddy config"
type="button"
variant="outlined"
color="gray"
size="small"
loading={loading}
onClick={() => void sync()}
beforeIcon={<RefreshCw size={14} />}
>
Sync
</Button>
<Button
title="Refresh TLS status"
type="button"
variant="outlined"
color="gray"
size="small"
loading={loading}
onClick={refresh}
>
Refresh
</Button>
</>
}
>
<Stack className="gap-4 items-stretch">
{error ? <InlineAlert tone="error" msg={error} /> : null}
{!caddy?.installed ? (
<InlineAlert
tone="warning"
msg="Caddy is not installed yet. Run the installer/update script, or install it to /opt/caddy/caddy."
/>
) : null}
{caddy?.state?.last_error ? (
<InlineAlert
tone="error"
msg={`Last reload: ${caddy.state.last_error}`}
/>
) : null}
{caddy?.state?.warnings?.length ? (
<InlineAlert
tone="warning"
msg={caddy.state.warnings.join("; ")}
/>
) : null}
<KeyValueList
items={[
{
key: "ingress",
label: "HTTPS port",
value: caddy
? `${caddy.https_port}/tcp`
: "—",
},
{
key: "config",
label: "Caddyfile",
value: caddy?.config_path || "—",
mono: true,
},
{
key: "reload",
label: "Last reload",
value: caddy?.state?.last_reload_at
? new Date(
caddy.state.last_reload_at,
).toLocaleString()
: "Never",
},
]}
/>
</Stack>
</Panel>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import guardSetupPage from "../(functions)/guard-setup-page";
const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const redirect = await guardSetupPage({ req });
if (redirect) {
return { redirect };
}
return { props: {} };
};
export default server;
+27
View File
@@ -0,0 +1,27 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import useTlsStatus from "../(hooks)/use-tls-status";
import SetupPageFrame from "../(sections)/setup-page-frame";
import TlsStatusSection from "./(sections)/tls-status-section";
import TlsInstallSection from "./(sections)/tls-install-section";
import TlsSetupSection from "./(sections)/tls-setup-section";
export default function SetupTlsPage() {
const tls = useTlsStatus();
return (
<SetupPageFrame
title="TLS ingress"
description="Serve configured client routes over HTTPS without touching the host's ports 80 or 443."
>
<TlsStatusSection tls={tls} />
<TlsInstallSection tls={tls} />
<TlsSetupSection tls={tls} />
</SetupPageFrame>
);
}
export const meta: BunextPageModuleMeta = {
title: `TLS ingress | ${SiteData["SiteName"]}`,
description: `Caddy ingress and client-route certificates`,
};