Add TLS feature with Caddy
This commit is contained in:
@@ -30,14 +30,23 @@ export default async function submitClientForm({
|
||||
const { setLoading, setStatus, form, existing_full, app_context, clearLocalForm } = init;
|
||||
const is_edit = Boolean(existing_full?.id);
|
||||
|
||||
if (is_edit) {
|
||||
const confirmed = await confirm({
|
||||
title: "Save changes?",
|
||||
message: "The client's config is regenerated. Devices may need the updated config.",
|
||||
confirm_label: "Save changes",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
const confirmed = await confirm(
|
||||
is_edit
|
||||
? {
|
||||
title: "Save changes?",
|
||||
message:
|
||||
"The client's config is regenerated. Devices may need the updated config.",
|
||||
confirm_label: "Save changes",
|
||||
}
|
||||
: {
|
||||
title: "Create this client?",
|
||||
message:
|
||||
"A key pair and WireGuard config will be generated for this client.",
|
||||
confirm_label: "Create client",
|
||||
},
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
|
||||
import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import type { ClientRouteFormObject } from "./types";
|
||||
import Form from "@/src/components/twui/form/Form";
|
||||
import Input from "@/src/components/twui/form/Input";
|
||||
import Checkbox from "@/src/components/twui/form/Checkbox";
|
||||
import LoadingBlock from "@/src/components/twui/elements/LoadingBlock";
|
||||
import useFormInit from "@/src/hooks/use-form-init";
|
||||
import useConfirm from "@/src/hooks/use-confirm";
|
||||
import Panel from "../panel";
|
||||
import FormSection from "../form-section";
|
||||
import FormActions from "../form-actions";
|
||||
import InlineAlert from "../inline-alert";
|
||||
import submitClientRouteForm from "./submit-client-route-form";
|
||||
|
||||
type Props = {
|
||||
host_id: number;
|
||||
existing_route?: ClientRouteView;
|
||||
cancel_href: string;
|
||||
};
|
||||
|
||||
export default function ClientRouteForm({
|
||||
host_id,
|
||||
existing_route,
|
||||
cancel_href,
|
||||
}: Props) {
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const init = useFormInit<ClientRouteFormObject>({
|
||||
existing: existing_route,
|
||||
existing_full: existing_route,
|
||||
title: existing_route?.id
|
||||
? `Edit route v2 ${existing_route.id}`
|
||||
: `Add client route v2 ${host_id}`,
|
||||
local_storage_exclude_keys: ["acme_credentials"],
|
||||
});
|
||||
|
||||
const { form, setForm, loading, status, ready } = init;
|
||||
const is_edit = Boolean(existing_route?.id);
|
||||
|
||||
if (!ready) {
|
||||
return <LoadingBlock className="h-[420px]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<Form
|
||||
className="w-full gap-0"
|
||||
onSubmit={() =>
|
||||
submitClientRouteForm({
|
||||
init,
|
||||
host_id,
|
||||
cancel_href,
|
||||
confirm,
|
||||
})
|
||||
}
|
||||
>
|
||||
{status?.error ? (
|
||||
<InlineAlert tone="error" msg={status.msg} className="mb-6" />
|
||||
) : null}
|
||||
|
||||
<FormSection
|
||||
title="Route"
|
||||
description="The public hostname and path Caddy matches."
|
||||
>
|
||||
<Input
|
||||
name="name"
|
||||
title="Name"
|
||||
placeholder="e.g. Jellyfin"
|
||||
defaultValue={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
name: e.target.value,
|
||||
}))
|
||||
}
|
||||
showLabel
|
||||
/>
|
||||
<Input
|
||||
name="hostname"
|
||||
title="Hostname"
|
||||
placeholder="media.example.com"
|
||||
defaultValue={form.hostname}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
hostname: e.target.value.toLowerCase(),
|
||||
}))
|
||||
}
|
||||
showLabel
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
name="path_prefix"
|
||||
title="Path prefix"
|
||||
placeholder="/"
|
||||
defaultValue={form.path_prefix || "/"}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
path_prefix: e.target.value,
|
||||
}))
|
||||
}
|
||||
showLabel
|
||||
info="Use / for the whole hostname, or /app to match one subpath."
|
||||
/>
|
||||
<Checkbox
|
||||
title="Strip the path prefix before forwarding"
|
||||
label="Strip path prefix"
|
||||
checked={form.strip_prefix == 1}
|
||||
changeHandler={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
strip_prefix: value ? 1 : 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="Upstream"
|
||||
description="The internal service Caddy forwards to. It must be reachable from this host."
|
||||
>
|
||||
<Input
|
||||
name="upstream_url"
|
||||
title="Upstream URL"
|
||||
placeholder="http://10.5.0.20:8080"
|
||||
defaultValue={form.upstream_url}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
upstream_url: e.target.value,
|
||||
}))
|
||||
}
|
||||
showLabel
|
||||
required
|
||||
info="Bare origin only: http(s)://host:port, with no path, query or credentials."
|
||||
/>
|
||||
<Checkbox
|
||||
title="Verify the upstream TLS certificate"
|
||||
label="Verify upstream TLS"
|
||||
checked={form.upstream_tls_verify != 0}
|
||||
changeHandler={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
upstream_tls_verify: value ? 1 : 0,
|
||||
}))
|
||||
}
|
||||
info="When disabled, Caddy accepts any upstream certificate. Use only for internal self-signed services."
|
||||
/>
|
||||
<Checkbox
|
||||
title="Serve this route"
|
||||
label="Enabled"
|
||||
checked={form.enabled != 0}
|
||||
changeHandler={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
enabled: value ? 1 : 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="TLS certificate"
|
||||
description="Manual DNS-01. After saving, the route page shows the TXT record to add at your registrar."
|
||||
>
|
||||
<InlineAlert
|
||||
tone="info"
|
||||
msg="No DNS provider token is needed. Issue the certificate from the route page, add the displayed _acme-challenge TXT record at Cloudflare, then click Validate."
|
||||
/>
|
||||
<Input
|
||||
name="acme_email"
|
||||
title="ACME email"
|
||||
placeholder="[email protected]"
|
||||
type="email"
|
||||
defaultValue={form.acme_email}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
acme_email: e.target.value,
|
||||
}))
|
||||
}
|
||||
showLabel
|
||||
/>
|
||||
<Checkbox
|
||||
title="Use the Let's Encrypt staging endpoint"
|
||||
label="Use staging CA"
|
||||
checked={form.acme_staging == 1}
|
||||
changeHandler={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
acme_staging: value ? 1 : 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="Notes"
|
||||
description="Optional context for the next admin."
|
||||
>
|
||||
<Input
|
||||
name="notes"
|
||||
title="Notes"
|
||||
istextarea
|
||||
defaultValue={form.notes}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
notes: e.target.value,
|
||||
}))
|
||||
}
|
||||
showLabel
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormActions
|
||||
submit_title={is_edit ? "Save changes" : "Create route"}
|
||||
loading={loading}
|
||||
cancel_href={cancel_href}
|
||||
/>
|
||||
</Form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
|
||||
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
|
||||
import type { ClientRouteFormInit } from "./types";
|
||||
import type { ConfirmContextValue } from "../confirm/confirm-context";
|
||||
|
||||
type Params = {
|
||||
init: ClientRouteFormInit;
|
||||
host_id: number;
|
||||
cancel_href: string;
|
||||
confirm: ConfirmContextValue["confirm"];
|
||||
};
|
||||
|
||||
export default async function submitClientRouteForm({
|
||||
init,
|
||||
host_id,
|
||||
cancel_href,
|
||||
confirm,
|
||||
}: Params) {
|
||||
const { form, existing_full, setLoading, setStatus, clearLocalForm } = init;
|
||||
const is_edit = Boolean(existing_full?.id);
|
||||
|
||||
const confirmed = await confirm(
|
||||
is_edit
|
||||
? {
|
||||
title: "Save route changes?",
|
||||
message:
|
||||
"Caddy will regenerate its config and reload the TLS ingress.",
|
||||
confirm_label: "Save changes",
|
||||
}
|
||||
: {
|
||||
title: "Create this route?",
|
||||
message:
|
||||
"Caddy will regenerate its config and reload the TLS ingress.",
|
||||
confirm_label: "Create route",
|
||||
},
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const data: BUN_SQLITE_WGUI_CLIENT_ROUTES = {
|
||||
host_id,
|
||||
name: form.name || "",
|
||||
hostname: form.hostname || "",
|
||||
path_prefix: form.path_prefix || "/",
|
||||
strip_prefix: form.strip_prefix == 1 ? 1 : 0,
|
||||
upstream_url: form.upstream_url || "",
|
||||
upstream_tls_verify: form.upstream_tls_verify == 0 ? 0 : 1,
|
||||
enabled: form.enabled == 0 ? 0 : 1,
|
||||
notes: form.notes || "",
|
||||
acme_email: form.acme_email || "",
|
||||
acme_staging: form.acme_staging == 1 ? 1 : 0,
|
||||
};
|
||||
|
||||
const res = is_edit
|
||||
? await adminCrudHandler({
|
||||
action: "update",
|
||||
table: "client_routes",
|
||||
update_data: data,
|
||||
id: existing_full?.id,
|
||||
})
|
||||
: await adminCrudHandler({
|
||||
action: "insert",
|
||||
table: "client_routes",
|
||||
insert_data: [data],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.msg || "Could not save the route");
|
||||
}
|
||||
|
||||
const route_id =
|
||||
existing_full?.id ||
|
||||
res.postInsertReturn?.insertId ||
|
||||
res.singleRes?.id;
|
||||
|
||||
clearLocalForm();
|
||||
window.location.href = route_id
|
||||
? `/admin/hosts/${host_id}/routes/${route_id}`
|
||||
: cancel_href;
|
||||
} catch (error: any) {
|
||||
setStatus({
|
||||
error: true,
|
||||
msg: error.message || "Could not save the route",
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import type useFormInit from "@/src/hooks/use-form-init";
|
||||
|
||||
export type ClientRouteFormObject = Omit<ClientRouteView, "acme_credentials"> & {
|
||||
acme_credentials?: string;
|
||||
};
|
||||
|
||||
export type ClientRouteFormInit = ReturnType<
|
||||
typeof useFormInit<ClientRouteFormObject>
|
||||
>;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Network, Plus } from "lucide-react";
|
||||
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
||||
import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Panel from "../panel";
|
||||
import InlineAlert from "../inline-alert";
|
||||
import EmptyState from "../empty-state";
|
||||
import ClientRoutesTable from "../client-routes-table";
|
||||
import useToast from "@/src/hooks/use-toast";
|
||||
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
||||
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
|
||||
|
||||
type Props = {
|
||||
host_id?: number;
|
||||
show_host?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
add_href?: string;
|
||||
};
|
||||
|
||||
export default function ClientRoutesPanel({
|
||||
host_id,
|
||||
show_host,
|
||||
title = "Routes",
|
||||
description,
|
||||
add_href,
|
||||
}: Props) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { res, setRes } = useAdminCrudGet<ClientRouteView>({
|
||||
table: "client_routes",
|
||||
sql_query: {
|
||||
query: host_id !== undefined
|
||||
? { host_id: { value: String(host_id) } }
|
||||
: undefined,
|
||||
order: { field: "hostname", strategy: "ASC" },
|
||||
limit: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const hosts_query = useAdminCrudGet<BUN_SQLITE_WGUI_HOSTS>({
|
||||
table: "hosts",
|
||||
ready: Boolean(show_host),
|
||||
sql_query: {
|
||||
order: { field: "id", strategy: "ASC" },
|
||||
},
|
||||
});
|
||||
|
||||
const hosts_by_id = useMemo(() => {
|
||||
const map = new Map<number, BUN_SQLITE_WGUI_HOSTS>();
|
||||
|
||||
for (const host of hosts_query.res || []) {
|
||||
if (typeof host.id === "number") {
|
||||
map.set(host.id, host);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [hosts_query.res]);
|
||||
|
||||
const onDeleted = useCallback(
|
||||
async ({ id }: { id: number }) => {
|
||||
const deleted = await adminCrudHandler({
|
||||
action: "delete",
|
||||
table: "client_routes",
|
||||
id,
|
||||
});
|
||||
|
||||
if (deleted.success) {
|
||||
setRes((prev) => prev?.filter((route) => route.id != id));
|
||||
toast({ msg: "Route deleted", color: "success" });
|
||||
} else {
|
||||
toast({
|
||||
msg: deleted.msg || "Could not delete route",
|
||||
color: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[setRes, toast],
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
flush
|
||||
title={title}
|
||||
description={description || (res ? `${res.length} configured` : undefined)}
|
||||
actions={
|
||||
add_href ? (
|
||||
<Button
|
||||
title="Add route"
|
||||
href={add_href}
|
||||
size="small"
|
||||
beforeIcon={<Plus size={14} />}
|
||||
>
|
||||
Add route
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Stack className="px-4 pt-4 items-stretch">
|
||||
<InlineAlert
|
||||
tone="info"
|
||||
msg="Certificates are configured and issued on each route page."
|
||||
/>
|
||||
</Stack>
|
||||
<ClientRoutesTable
|
||||
routes={res}
|
||||
hosts_by_id={hosts_by_id}
|
||||
show_host={show_host}
|
||||
loading={!res}
|
||||
onDeleted={onDeleted}
|
||||
empty={
|
||||
<EmptyState
|
||||
icon={<Network size={18} />}
|
||||
title="No routes yet"
|
||||
description="Expose an internal HTTP service to WireGuard clients over HTTPS."
|
||||
action={
|
||||
add_href ? (
|
||||
<Button
|
||||
title="Add route"
|
||||
href={add_href}
|
||||
size="small"
|
||||
>
|
||||
Add route
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Card from "@/src/components/twui/elements/Card";
|
||||
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
||||
import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import MutedText from "../muted-text";
|
||||
import StatusDot from "../status-dot";
|
||||
import ClientRouteNameCell from "./client-route-name-cell";
|
||||
import ClientRouteRowActions from "./client-route-row-actions";
|
||||
|
||||
type Props = {
|
||||
route: ClientRouteView;
|
||||
hosts_by_id?: Map<number, BUN_SQLITE_WGUI_HOSTS>;
|
||||
show_host?: boolean;
|
||||
onDeleted?: ({ id }: { id: number }) => void;
|
||||
};
|
||||
|
||||
export default function ClientRouteMobileCard({
|
||||
route,
|
||||
hosts_by_id,
|
||||
show_host,
|
||||
onDeleted,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card noHover className="p-4">
|
||||
<Stack className="gap-2">
|
||||
<Row className="justify-between gap-2">
|
||||
<Stack className="gap-1 min-w-0">
|
||||
<ClientRouteNameCell route={route} />
|
||||
{show_host ? (
|
||||
<MutedText>
|
||||
{hosts_by_id?.get(Number(route.host_id || 0))
|
||||
?.name || `Host #${Number(route.host_id || 0)}`}
|
||||
</MutedText>
|
||||
) : null}
|
||||
<MutedText>{route.upstream_url || "—"}</MutedText>
|
||||
</Stack>
|
||||
<ClientRouteRowActions route={route} onDeleted={onDeleted} />
|
||||
</Row>
|
||||
<Row className="gap-3 flex-wrap">
|
||||
<MutedText>
|
||||
{route.path_prefix || "/"} ·{" "}
|
||||
{route.enabled == 0 ? "Disabled" : "Enabled"}
|
||||
</MutedText>
|
||||
<StatusDot
|
||||
tone={
|
||||
route.certificate_status == "active"
|
||||
? "success"
|
||||
: route.certificate_status == "error"
|
||||
? "error"
|
||||
: route.certificate_status == "expiring" ||
|
||||
route.certificate_status == "issuing"
|
||||
? "warning"
|
||||
: "neutral"
|
||||
}
|
||||
label={route.certificate_status || "missing"}
|
||||
/>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "@/src/components/twui/layout/Link";
|
||||
import MonoText from "../mono-text";
|
||||
import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route";
|
||||
|
||||
type Props = {
|
||||
route: ClientRouteView;
|
||||
};
|
||||
|
||||
export default function ClientRouteNameCell({ route }: Props) {
|
||||
const host_id = Number(route.host_id || 0);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/hosts/${host_id}/routes/${Number(route.id || 0)}`}
|
||||
className="border-b-0! no-underline!"
|
||||
>
|
||||
<MonoText className="text-fg hover:text-primary">
|
||||
{route.hostname || route.name || "—"}
|
||||
</MonoText>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import ActionMenu from "../action-menu";
|
||||
import useConfirm from "@/src/hooks/use-confirm";
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
|
||||
|
||||
type Props = {
|
||||
route: BUN_SQLITE_WGUI_CLIENT_ROUTES;
|
||||
onDeleted?: ({ id }: { id: number }) => void;
|
||||
};
|
||||
|
||||
export default function ClientRouteRowActions({ route, onDeleted }: Props) {
|
||||
const { confirm } = useConfirm();
|
||||
const route_id = Number(route.id || 0);
|
||||
const host_id = Number(route.host_id || 0);
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
label={`Actions for ${route.name || route.hostname || route_id}`}
|
||||
className="items-end"
|
||||
items={[
|
||||
{
|
||||
key: "open",
|
||||
title: "Open",
|
||||
href: `/admin/hosts/${host_id}/routes/${route_id}`,
|
||||
icon: <Eye size={14} />,
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
title: "Edit",
|
||||
href: `/admin/hosts/${host_id}/routes/edit/${route_id}`,
|
||||
icon: <Pencil size={14} />,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete",
|
||||
tone: "danger",
|
||||
icon: <Trash2 size={14} />,
|
||||
onClick: async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete route?",
|
||||
message: `${route.hostname} will stop being served by Caddy.`,
|
||||
confirm_label: "Delete",
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
onDeleted?.({ id: route_id });
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
||||
import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import type { DataTableColumn } from "../data-table/types";
|
||||
import MonoText from "../mono-text";
|
||||
import MutedText from "../muted-text";
|
||||
import StatusDot from "../status-dot";
|
||||
import ClientRouteNameCell from "./client-route-name-cell";
|
||||
import ClientRouteRowActions from "./client-route-row-actions";
|
||||
|
||||
type Params = {
|
||||
onDeleted?: ({ id }: { id: number }) => void;
|
||||
hosts_by_id?: Map<number, BUN_SQLITE_WGUI_HOSTS>;
|
||||
show_host?: boolean;
|
||||
};
|
||||
|
||||
function certificateTone(status?: string) {
|
||||
if (status == "active") return "success" as const;
|
||||
if (status == "expiring" || status == "issuing") return "warning" as const;
|
||||
if (status == "error") return "error" as const;
|
||||
return "neutral" as const;
|
||||
}
|
||||
|
||||
export default function buildClientRoutesTableColumns({
|
||||
onDeleted,
|
||||
hosts_by_id,
|
||||
show_host,
|
||||
}: Params): DataTableColumn<ClientRouteView>[] {
|
||||
const columns: DataTableColumn<ClientRouteView>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Route",
|
||||
render: ({ item }) => <ClientRouteNameCell route={item} />,
|
||||
},
|
||||
];
|
||||
|
||||
if (show_host) {
|
||||
columns.push({
|
||||
key: "host",
|
||||
header: "Host",
|
||||
hide_below: "lg",
|
||||
render: ({ item }) => (
|
||||
<MutedText>
|
||||
{hosts_by_id?.get(Number(item.host_id || 0))?.name ||
|
||||
`Host #${Number(item.host_id || 0)}`}
|
||||
</MutedText>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
columns.push(
|
||||
{
|
||||
key: "path",
|
||||
header: "Path",
|
||||
render: ({ item }) => <MonoText>{item.path_prefix || "/"}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: "upstream",
|
||||
header: "Upstream",
|
||||
hide_below: "lg",
|
||||
render: ({ item }) => (
|
||||
<MonoText className="text-muted">{item.upstream_url || "—"}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "certificate",
|
||||
header: "Certificate",
|
||||
render: ({ item }) => (
|
||||
<StatusDot
|
||||
tone={certificateTone(item.certificate_status)}
|
||||
label={item.certificate_status || "missing"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "state",
|
||||
header: "State",
|
||||
render: ({ item }) =>
|
||||
item.enabled == 0 ? (
|
||||
<MutedText>Disabled</MutedText>
|
||||
) : (
|
||||
<MutedText>Enabled</MutedText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: <span className="sr-only">Actions</span>,
|
||||
align: "right",
|
||||
className: "w-10",
|
||||
render: ({ item }) => (
|
||||
<ClientRouteRowActions route={item} onDeleted={onDeleted} />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
return columns;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
||||
import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import DataTable from "../data-table";
|
||||
import buildClientRoutesTableColumns from "./client-routes-table-columns";
|
||||
import ClientRouteMobileCard from "./client-route-mobile-card";
|
||||
|
||||
type Props = {
|
||||
routes?: ClientRouteView[];
|
||||
hosts_by_id?: Map<number, BUN_SQLITE_WGUI_HOSTS>;
|
||||
show_host?: boolean;
|
||||
loading?: boolean;
|
||||
empty?: ReactNode;
|
||||
onDeleted?: ({ id }: { id: number }) => void;
|
||||
};
|
||||
|
||||
export default function ClientRoutesTable({
|
||||
routes,
|
||||
hosts_by_id,
|
||||
show_host,
|
||||
loading,
|
||||
empty,
|
||||
onDeleted,
|
||||
}: Props) {
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
buildClientRoutesTableColumns({
|
||||
onDeleted,
|
||||
hosts_by_id,
|
||||
show_host,
|
||||
}),
|
||||
[onDeleted, hosts_by_id, show_host],
|
||||
);
|
||||
|
||||
const MobileItem = useCallback(
|
||||
({ item }: { item: ClientRouteView }) => (
|
||||
<ClientRouteMobileCard
|
||||
route={item}
|
||||
hosts_by_id={hosts_by_id}
|
||||
show_host={show_host}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
),
|
||||
[hosts_by_id, show_host, onDeleted],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
caption="Client routes"
|
||||
items={routes}
|
||||
columns={columns}
|
||||
getKey={({ item }) => Number(item.id)}
|
||||
loading={loading}
|
||||
empty={empty}
|
||||
skeleton_rows={3}
|
||||
MobileItem={MobileItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import Form from "@/src/components/twui/form/Form";
|
||||
import useFormInit from "@/src/hooks/use-form-init";
|
||||
import useToast from "@/src/hooks/use-toast";
|
||||
import useConfirm from "@/src/hooks/use-confirm";
|
||||
import type { UserFormObject } from "@/src/types";
|
||||
import type { BUN_SQLITE_WGUI_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import InlineAlert from "../inline-alert";
|
||||
@@ -22,6 +23,7 @@ type Props = {
|
||||
|
||||
export default function UserForm({ existing_user, existing_user_full, is_first_user }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const init = useFormInit<UserFormObject>({
|
||||
existing: existing_user,
|
||||
@@ -40,7 +42,7 @@ export default function UserForm({ existing_user, existing_user_full, is_first_u
|
||||
className="w-full gap-5"
|
||||
onSubmit={() => {
|
||||
if (!passwords_match) return;
|
||||
submitUserForm({ init, toast });
|
||||
submitUserForm({ init, toast, confirm });
|
||||
}}
|
||||
>
|
||||
{status?.error ? <InlineAlert tone="error" msg={status.msg} /> : null}
|
||||
|
||||
@@ -12,15 +12,40 @@ import fetchApi from "../../twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
import type { ToastContextValue } from "../toast/toast-context";
|
||||
import type { ConfirmContextValue } from "../confirm/confirm-context";
|
||||
|
||||
type Params = {
|
||||
init: ReturnType<typeof useFormInit<UserFormObject>>;
|
||||
toast: ToastContextValue["toast"];
|
||||
confirm: ConfirmContextValue["confirm"];
|
||||
};
|
||||
|
||||
export default async function submitUserForm({ init, toast }: Params) {
|
||||
export default async function submitUserForm({ init, toast, confirm }: Params) {
|
||||
const { setLoading, setStatus, form, existing_full, image, is_first_user } = init;
|
||||
|
||||
const is_update =
|
||||
existing_full?.id !== undefined && existing_full?.id !== "";
|
||||
const confirmed = await confirm(
|
||||
is_first_user
|
||||
? {
|
||||
title: "Create the admin account?",
|
||||
message:
|
||||
"This account becomes the super admin and is used to sign in.",
|
||||
confirm_label: "Create account",
|
||||
}
|
||||
: is_update
|
||||
? {
|
||||
title: "Save profile changes?",
|
||||
confirm_label: "Save changes",
|
||||
}
|
||||
: {
|
||||
title: "Create this user?",
|
||||
confirm_label: "Create user",
|
||||
},
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const new_user_data: BUN_SQLITE_WGUI_USERS = _.pick<
|
||||
UserFormObject,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
function parsePort({
|
||||
value,
|
||||
fallback,
|
||||
}: {
|
||||
value?: string;
|
||||
fallback: number;
|
||||
}) {
|
||||
const parsed = Number(value);
|
||||
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const TlsData = {
|
||||
/** Public HTTPS port Caddy listens on. Never 80/443. */
|
||||
CaddyHttpsPort: parsePort({
|
||||
value: process.env.CADDY_HTTPS_PORT,
|
||||
fallback: 8443,
|
||||
}),
|
||||
/** Plain HTTP port for Caddy redirects/bootstrap. Never port 80. */
|
||||
CaddyHttpPort: parsePort({
|
||||
value: process.env.CADDY_HTTP_PORT,
|
||||
fallback: 8081,
|
||||
}),
|
||||
/** Default acme.sh DNS provider id, e.g. dns_cf. */
|
||||
DefaultDnsProvider: process.env.ACME_DNS_PROVIDER || "",
|
||||
/** Use the Let's Encrypt staging endpoint. */
|
||||
AcmeStaging: process.env.ACME_STAGING == "1",
|
||||
/** Keep the ingress port closed unless a route is enabled. */
|
||||
IngressEnabled: process.env.CADDY_INGRESS_ENABLED != "0",
|
||||
RenewBeforeDays: 30,
|
||||
} as const;
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as acme from "acme-client";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import grabCertDirNames from "@/src/functions/backend/caddy/grab-cert-dir-names";
|
||||
import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress";
|
||||
import grabCertificateByHostname from "./grab-certificate-by-hostname";
|
||||
import fixCertificatePermissions from "./fix-certificate-permissions";
|
||||
import {
|
||||
grabManualDnsChallenge,
|
||||
removeManualDnsChallenge,
|
||||
} from "./manual-dns-challenges";
|
||||
import readCertificateExpiry from "./read-certificate-expiry";
|
||||
import recordCertificate from "./record-certificate";
|
||||
|
||||
type Params = {
|
||||
route_id?: number | "";
|
||||
user_id?: number | "";
|
||||
};
|
||||
|
||||
export default async function completeManualCertificate({
|
||||
route_id,
|
||||
user_id,
|
||||
}: Params): Promise<APIResponseObject> {
|
||||
const final_route_id = Number(route_id);
|
||||
|
||||
if (!Number.isSafeInteger(final_route_id) || final_route_id < 1) {
|
||||
return { success: false, msg: `Invalid route` };
|
||||
}
|
||||
|
||||
const challenge_state = grabManualDnsChallenge(final_route_id);
|
||||
|
||||
if (!challenge_state) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `No pending certificate request — click Issue/Renew again`,
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
client,
|
||||
order,
|
||||
challenge,
|
||||
authz,
|
||||
hostname,
|
||||
staging,
|
||||
} = challenge_state;
|
||||
const { CERT_DIR, CERT_FILE, KEY_FILE } = grabCertDirNames({ hostname });
|
||||
|
||||
try {
|
||||
await client.completeChallenge(challenge);
|
||||
await client.waitForValidStatus(challenge);
|
||||
|
||||
const [key, csr] = await acme.crypto.createCsr({
|
||||
commonName: hostname,
|
||||
altNames: [hostname],
|
||||
});
|
||||
|
||||
const finalized = await client.finalizeOrder(order, csr);
|
||||
const certificate = await client.getCertificate(finalized);
|
||||
|
||||
mkdirSync(CERT_DIR, { recursive: true, mode: 0o750 });
|
||||
writeFileSync(KEY_FILE, key, { mode: 0o600 });
|
||||
writeFileSync(CERT_FILE, certificate, { mode: 0o644 });
|
||||
|
||||
fixCertificatePermissions({
|
||||
cert_dir: CERT_DIR,
|
||||
cert_file: CERT_FILE,
|
||||
key_file: KEY_FILE,
|
||||
});
|
||||
|
||||
const expires_at = readCertificateExpiry({ cert_path: CERT_FILE });
|
||||
|
||||
await recordCertificate({
|
||||
certificate: {
|
||||
user_id: user_id,
|
||||
hostname,
|
||||
provider: "letsencrypt",
|
||||
challenge: "dns-01",
|
||||
dns_provider: "manual",
|
||||
cert_path: CERT_FILE,
|
||||
key_path: KEY_FILE,
|
||||
expires_at: expires_at || undefined,
|
||||
status: "active",
|
||||
last_error: "",
|
||||
auto_renew: 0,
|
||||
},
|
||||
});
|
||||
|
||||
removeManualDnsChallenge(final_route_id);
|
||||
|
||||
const sync = await syncCaddyIngress({
|
||||
reason: "manual certificate issued",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Certificate issued for ${hostname}`,
|
||||
stringRes: hostname,
|
||||
singleRes: await grabCertificateByHostname({ hostname }),
|
||||
debug: { warnings: sync.warnings },
|
||||
};
|
||||
} catch (error: any) {
|
||||
const msg = error.message || `Certificate validation failed`;
|
||||
const existing_expiry = readCertificateExpiry({
|
||||
cert_path: CERT_FILE,
|
||||
});
|
||||
|
||||
await recordCertificate({
|
||||
certificate: {
|
||||
user_id: user_id,
|
||||
hostname,
|
||||
provider: "letsencrypt",
|
||||
challenge: "dns-01",
|
||||
dns_provider: "manual",
|
||||
cert_path: CERT_FILE,
|
||||
key_path: KEY_FILE,
|
||||
expires_at: existing_expiry || undefined,
|
||||
status: existing_expiry ? "active" : "error",
|
||||
last_error: msg,
|
||||
auto_renew: 0,
|
||||
},
|
||||
});
|
||||
|
||||
removeManualDnsChallenge(final_route_id);
|
||||
|
||||
// The authorization is usually invalid after a failed validation.
|
||||
try {
|
||||
await client.deactivateAuthorization(authz);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
msg,
|
||||
stringRes: hostname,
|
||||
singleRes: await grabCertificateByHostname({ hostname }),
|
||||
debug: { staging },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BUN_SQLITE_WGUI_TLS_CERTIFICATES } from "@/db/types/db";
|
||||
import readCertificateExpiry from "./read-certificate-expiry";
|
||||
|
||||
export type ClientCertificateView = BUN_SQLITE_WGUI_TLS_CERTIFICATES & {
|
||||
expires_at?: number | null;
|
||||
has_certificate?: boolean;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
certificate?: BUN_SQLITE_WGUI_TLS_CERTIFICATES | null;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
export default function decorateCertificate({
|
||||
certificate,
|
||||
hostname,
|
||||
}: Params): ClientCertificateView {
|
||||
const expires_at = readCertificateExpiry({
|
||||
cert_path: certificate?.cert_path,
|
||||
});
|
||||
const now = Date.now();
|
||||
const renew_before = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let status = certificate?.status || "missing";
|
||||
|
||||
if (expires_at) {
|
||||
status =
|
||||
expires_at <= now
|
||||
? "error"
|
||||
: expires_at - now <= renew_before
|
||||
? "expiring"
|
||||
: certificate?.status == "issuing"
|
||||
? "issuing"
|
||||
: "active";
|
||||
} else if (status == "active") {
|
||||
status = "missing";
|
||||
}
|
||||
|
||||
return {
|
||||
...(certificate || {}),
|
||||
hostname: certificate?.hostname || hostname,
|
||||
expires_at: expires_at || undefined,
|
||||
status,
|
||||
has_certificate: Boolean(expires_at),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import decorateClientRoute from "./decorate-client-route";
|
||||
|
||||
describe("decorateClientRoute", () => {
|
||||
test("never exposes encrypted credentials", () => {
|
||||
const route = decorateClientRoute({
|
||||
route: {
|
||||
id: 1,
|
||||
host_id: 0,
|
||||
hostname: "app.example.com",
|
||||
acme_credentials: "encrypted-value",
|
||||
},
|
||||
});
|
||||
|
||||
expect("acme_credentials" in route).toBe(false);
|
||||
expect(route.acme_credentials_configured).toBe(true);
|
||||
expect(route.certificate_status).toBe("missing");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
} from "@/db/types/db";
|
||||
import readCertificateExpiry from "./read-certificate-expiry";
|
||||
|
||||
export type ClientRouteView = BUN_SQLITE_WGUI_CLIENT_ROUTES & {
|
||||
acme_credentials?: never;
|
||||
acme_credentials_configured?: boolean;
|
||||
certificate_status?: string;
|
||||
certificate_expires_at?: number | null;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
route: BUN_SQLITE_WGUI_CLIENT_ROUTES;
|
||||
certificate?: BUN_SQLITE_WGUI_TLS_CERTIFICATES | null;
|
||||
};
|
||||
|
||||
export default function decorateClientRoute({
|
||||
route,
|
||||
certificate,
|
||||
}: Params): ClientRouteView {
|
||||
const { acme_credentials, ...safe_route } = route;
|
||||
const expires_at = readCertificateExpiry({
|
||||
cert_path: certificate?.cert_path,
|
||||
});
|
||||
const now = Date.now();
|
||||
const renew_before = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let certificate_status = certificate?.status || "missing";
|
||||
|
||||
if (expires_at) {
|
||||
certificate_status =
|
||||
expires_at <= now
|
||||
? "error"
|
||||
: expires_at - now <= renew_before
|
||||
? "expiring"
|
||||
: "active";
|
||||
} else if (certificate_status == "active") {
|
||||
certificate_status = "missing";
|
||||
}
|
||||
|
||||
return {
|
||||
...safe_route,
|
||||
acme_credentials_configured: Boolean(acme_credentials),
|
||||
certificate_status,
|
||||
certificate_expires_at: expires_at || null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { chmodSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
type Params = {
|
||||
cert_dir: string;
|
||||
cert_file: string;
|
||||
key_file: string;
|
||||
};
|
||||
|
||||
export default function fixCertificatePermissions({
|
||||
cert_dir,
|
||||
cert_file,
|
||||
key_file,
|
||||
}: Params) {
|
||||
try {
|
||||
chmodSync(cert_dir, 0o750);
|
||||
chmodSync(cert_file, 0o640);
|
||||
chmodSync(key_file, 0o640);
|
||||
|
||||
for (const target of [cert_dir, cert_file, key_file]) {
|
||||
execFileSync("chown", ["root:wgui-caddy", target], {
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// The group may not exist in development; Caddy runs as root there.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as acme from "acme-client";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
|
||||
const { WGUI_LIB_ACME_DIR, WGUI_ACME_ACCOUNT_KEY_FILE } = grabDirNames();
|
||||
|
||||
let cached_key: string | null = null;
|
||||
|
||||
/**
|
||||
* A single ACME account key for the host. It is created on first use and
|
||||
* reused for every certificate/account registration.
|
||||
*/
|
||||
export default async function grabAcmeAccountKey(): Promise<string> {
|
||||
if (cached_key) {
|
||||
return cached_key;
|
||||
}
|
||||
|
||||
mkdirSync(WGUI_LIB_ACME_DIR, { recursive: true, mode: 0o700 });
|
||||
chmodSync(WGUI_LIB_ACME_DIR, 0o700);
|
||||
|
||||
if (existsSync(WGUI_ACME_ACCOUNT_KEY_FILE)) {
|
||||
cached_key = readFileSync(WGUI_ACME_ACCOUNT_KEY_FILE, "utf-8");
|
||||
return cached_key;
|
||||
}
|
||||
|
||||
const key = await acme.crypto.createPrivateKey();
|
||||
const pem = key.toString();
|
||||
|
||||
writeFileSync(WGUI_ACME_ACCOUNT_KEY_FILE, pem, { mode: 0o600 });
|
||||
chmodSync(WGUI_ACME_ACCOUNT_KEY_FILE, 0o600);
|
||||
cached_key = pem;
|
||||
|
||||
return pem;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { BUN_SQLITE_WGUI_TLS_CERTIFICATES } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { TableType } from "@/src/types";
|
||||
import decorateCertificate, { type ClientCertificateView } from "./decorate-certificate";
|
||||
|
||||
type Params = {
|
||||
hostname?: string | null;
|
||||
};
|
||||
|
||||
export default async function grabCertificateByHostname({
|
||||
hostname,
|
||||
}: Params): Promise<ClientCertificateView | null> {
|
||||
if (!hostname) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const certificates_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
TableType
|
||||
>({
|
||||
table: "tls_certificates",
|
||||
query: {
|
||||
query: {
|
||||
hostname: {
|
||||
value: hostname,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return decorateCertificate({
|
||||
certificate: certificates_res.singleRes || null,
|
||||
hostname,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
} from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { TableType } from "@/src/types";
|
||||
import { TlsData } from "@/src/data/tls-data";
|
||||
import grabCaddyStatus from "../caddy/grab-caddy-status";
|
||||
import grabCertDirNames from "../caddy/grab-cert-dir-names";
|
||||
import readAcmeEnv from "./read-acme-env";
|
||||
import readCertificateExpiry from "./read-certificate-expiry";
|
||||
|
||||
export default async function grabTlsStatus() {
|
||||
const certificates_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
TableType
|
||||
>({
|
||||
table: "tls_certificates",
|
||||
query: {
|
||||
order: { field: "hostname", strategy: "ASC" },
|
||||
},
|
||||
});
|
||||
|
||||
const routes_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
});
|
||||
|
||||
const acme_env = readAcmeEnv();
|
||||
const now = Date.now();
|
||||
const renew_before = TlsData.RenewBeforeDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
const certificates = (certificates_res.payload || []).map(
|
||||
(certificate) => {
|
||||
const expires_at = readCertificateExpiry({
|
||||
cert_path: certificate.cert_path,
|
||||
});
|
||||
|
||||
let status = certificate.status || "missing";
|
||||
|
||||
if (expires_at) {
|
||||
if (expires_at <= now) {
|
||||
status = "error";
|
||||
} else if (expires_at - now <= renew_before) {
|
||||
status = "expiring";
|
||||
} else if (status != "issuing") {
|
||||
status = "active";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...certificate,
|
||||
hostname: certificate.hostname?.trim().toLowerCase(),
|
||||
expires_at: expires_at || certificate.expires_at || null,
|
||||
status,
|
||||
has_certificate: Boolean(expires_at),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const known_hostnames = new Set(
|
||||
certificates
|
||||
.map((certificate) => certificate.hostname)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
for (let i = 0; i < (routes_res.payload || []).length; i++) {
|
||||
const route = routes_res.payload?.[i];
|
||||
const hostname = route?.hostname?.trim().toLowerCase();
|
||||
|
||||
if (!hostname || known_hostnames.has(hostname)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { CERT_FILE, KEY_FILE } = grabCertDirNames({ hostname });
|
||||
|
||||
certificates.push({
|
||||
hostname,
|
||||
provider: "letsencrypt",
|
||||
challenge: "dns-01",
|
||||
cert_path: CERT_FILE,
|
||||
key_path: KEY_FILE,
|
||||
status: "missing",
|
||||
auto_renew: 1,
|
||||
expires_at: null,
|
||||
has_certificate: false,
|
||||
});
|
||||
|
||||
known_hostnames.add(hostname);
|
||||
}
|
||||
|
||||
certificates.sort((a, b) =>
|
||||
(a.hostname || "").localeCompare(b.hostname || ""),
|
||||
);
|
||||
|
||||
return {
|
||||
caddy: grabCaddyStatus(),
|
||||
certificates,
|
||||
acme: {
|
||||
env_path: acme_env.exists ? `/etc/wgui/acme.env` : null,
|
||||
env_exists: acme_env.exists,
|
||||
provider:
|
||||
acme_env.values.ACME_DNS_PROVIDER ||
|
||||
TlsData.DefaultDnsProvider ||
|
||||
null,
|
||||
email_configured: Boolean(acme_env.values.ACME_EMAIL),
|
||||
credential_keys: acme_env.keys.filter(
|
||||
(key) =>
|
||||
key != "ACME_EMAIL" && key != "ACME_DNS_PROVIDER",
|
||||
),
|
||||
staging: TlsData.AcmeStaging,
|
||||
},
|
||||
https_port: TlsData.CaddyHttpsPort,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
import type { TableType } from "@/src/types";
|
||||
import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status";
|
||||
import grabCertDirNames from "../caddy/grab-cert-dir-names";
|
||||
import syncCaddyIngress from "../caddy/sync-caddy-ingress";
|
||||
import validateRouteHostname from "../caddy/validate-route-hostname";
|
||||
import grabCertificateByHostname from "./grab-certificate-by-hostname";
|
||||
import readCertificateExpiry from "./read-certificate-expiry";
|
||||
import readRouteAcmeConfig from "./read-route-acme-config";
|
||||
import recordCertificate from "./record-certificate";
|
||||
import redactSecrets from "./redact-secrets";
|
||||
|
||||
type Params = {
|
||||
route_id?: number | "";
|
||||
route?: BUN_SQLITE_WGUI_CLIENT_ROUTES | null;
|
||||
hostname?: string | null;
|
||||
user_id?: number | "";
|
||||
staging?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
const { WGUI_ACME_SCRIPT, WGUI_ACME_HOME } = grabDirNames();
|
||||
|
||||
const PROVIDER_PATTERN = /^dns_[a-z0-9_]+$/;
|
||||
|
||||
function formatOutput({ text, secrets }: { text?: string; secrets: string[] }) {
|
||||
const redacted = redactSecrets({ text, secrets });
|
||||
const trimmed = redacted.trim();
|
||||
|
||||
return trimmed.length > 4000 ? `${trimmed.slice(0, 4000)}…` : trimmed;
|
||||
}
|
||||
|
||||
function fixPermissions({ cert_dir, cert_file, key_file }: {
|
||||
cert_dir: string;
|
||||
cert_file: string;
|
||||
key_file: string;
|
||||
}) {
|
||||
try {
|
||||
chmodSync(cert_dir, 0o750);
|
||||
chmodSync(cert_file, 0o640);
|
||||
chmodSync(key_file, 0o640);
|
||||
|
||||
for (const target of [cert_dir, cert_file, key_file]) {
|
||||
execFileSync("chown", ["root:wgui-caddy", target], {
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// The group may not exist in development; Caddy runs as root there.
|
||||
}
|
||||
}
|
||||
|
||||
export default async function issueCertificate({
|
||||
route_id,
|
||||
route: passed_route,
|
||||
hostname: raw_hostname,
|
||||
user_id,
|
||||
staging,
|
||||
force,
|
||||
}: Params): Promise<APIResponseObject> {
|
||||
let route = passed_route || undefined;
|
||||
|
||||
if (!route && route_id !== undefined && route_id !== "") {
|
||||
const route_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
targetId: route_id,
|
||||
});
|
||||
|
||||
route = route_res.singleRes || undefined;
|
||||
}
|
||||
|
||||
if (!route && raw_hostname) {
|
||||
const route_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
query: {
|
||||
query: {
|
||||
hostname: {
|
||||
value: raw_hostname.trim().toLowerCase(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
route = route_res.singleRes || undefined;
|
||||
}
|
||||
|
||||
const hostname = validateRouteHostname({
|
||||
value: route?.hostname || raw_hostname,
|
||||
});
|
||||
|
||||
if (!hostname.success) {
|
||||
return { success: false, msg: hostname.msg };
|
||||
}
|
||||
|
||||
if (!route) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Route not found — open the route and save its TLS settings first`,
|
||||
};
|
||||
}
|
||||
|
||||
const acme = await readRouteAcmeConfig({ route });
|
||||
|
||||
if (!acme.success) {
|
||||
return { success: false, msg: acme.msg };
|
||||
}
|
||||
|
||||
const provider = acme.config.provider;
|
||||
const email = acme.config.email;
|
||||
const secrets = Object.values(acme.config.values);
|
||||
|
||||
if (!existsSync(WGUI_ACME_SCRIPT)) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `acme.sh is not installed at ${WGUI_ACME_SCRIPT}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!provider || !PROVIDER_PATTERN.test(provider)) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Set the DNS provider on this route's form (e.g. dns_cf)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Set the ACME email on this route's form`,
|
||||
};
|
||||
}
|
||||
|
||||
const { CERT_DIR, CERT_FILE, KEY_FILE } = grabCertDirNames({
|
||||
hostname: hostname.value,
|
||||
});
|
||||
|
||||
mkdirSync(CERT_DIR, { recursive: true, mode: 0o750 });
|
||||
|
||||
const setup_status = grabSystemSetupStatus();
|
||||
const reload_command =
|
||||
setup_status.init_system == "openrc"
|
||||
? `rc-service wgui-caddy reload`
|
||||
: `systemctl reload wgui-caddy`;
|
||||
|
||||
const child_env = {
|
||||
...process.env,
|
||||
...acme.config.values,
|
||||
LE_WORKING_DIR: WGUI_ACME_HOME,
|
||||
};
|
||||
|
||||
const use_staging =
|
||||
staging !== undefined ? staging : acme.config.staging;
|
||||
|
||||
await recordCertificate({
|
||||
certificate: {
|
||||
user_id,
|
||||
hostname: hostname.value,
|
||||
provider: "letsencrypt",
|
||||
challenge: "dns-01",
|
||||
dns_provider: provider,
|
||||
cert_path: CERT_FILE,
|
||||
key_path: KEY_FILE,
|
||||
status: "issuing",
|
||||
last_error: "",
|
||||
auto_renew: 1,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const issue_args = [
|
||||
"--issue",
|
||||
"--server",
|
||||
"letsencrypt",
|
||||
"--dns",
|
||||
provider,
|
||||
"--accountemail",
|
||||
email,
|
||||
"-d",
|
||||
hostname.value,
|
||||
"--keylength",
|
||||
"ec-256",
|
||||
];
|
||||
|
||||
if (use_staging) {
|
||||
issue_args.push("--staging");
|
||||
}
|
||||
|
||||
if (force) {
|
||||
issue_args.push("--force");
|
||||
}
|
||||
|
||||
const issue_output = execFileSync(WGUI_ACME_SCRIPT, issue_args, {
|
||||
env: child_env,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 300000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const install_output = execFileSync(
|
||||
WGUI_ACME_SCRIPT,
|
||||
[
|
||||
"--install-cert",
|
||||
"-d",
|
||||
hostname.value,
|
||||
"--key-file",
|
||||
KEY_FILE,
|
||||
"--fullchain-file",
|
||||
CERT_FILE,
|
||||
"--reloadcmd",
|
||||
reload_command,
|
||||
],
|
||||
{
|
||||
env: child_env,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 60000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
|
||||
fixPermissions({
|
||||
cert_dir: CERT_DIR,
|
||||
cert_file: CERT_FILE,
|
||||
key_file: KEY_FILE,
|
||||
});
|
||||
|
||||
const expires_at = readCertificateExpiry({ cert_path: CERT_FILE });
|
||||
|
||||
await recordCertificate({
|
||||
certificate: {
|
||||
user_id,
|
||||
hostname: hostname.value,
|
||||
provider: "letsencrypt",
|
||||
challenge: "dns-01",
|
||||
dns_provider: provider,
|
||||
cert_path: CERT_FILE,
|
||||
key_path: KEY_FILE,
|
||||
expires_at: expires_at || undefined,
|
||||
status: "active",
|
||||
last_error: "",
|
||||
auto_renew: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const sync = await syncCaddyIngress({ reason: "certificate issued" });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg:
|
||||
formatOutput({
|
||||
text: `${issue_output}\n${install_output}`,
|
||||
secrets,
|
||||
}) || `Certificate issued for ${hostname.value}`,
|
||||
stringRes: hostname.value,
|
||||
singleRes: await grabCertificateByHostname({
|
||||
hostname: hostname.value,
|
||||
}),
|
||||
debug: {
|
||||
warnings: sync.warnings,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
const stdout = error?.stdout ? String(error.stdout) : "";
|
||||
const stderr = error?.stderr ? String(error.stderr) : "";
|
||||
const msg = formatOutput({
|
||||
text: [stdout, stderr, error.message]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
secrets,
|
||||
});
|
||||
|
||||
await recordCertificate({
|
||||
certificate: {
|
||||
user_id,
|
||||
hostname: hostname.value,
|
||||
provider: "letsencrypt",
|
||||
challenge: "dns-01",
|
||||
dns_provider: provider,
|
||||
cert_path: CERT_FILE,
|
||||
key_path: KEY_FILE,
|
||||
status: "error",
|
||||
last_error: msg || `Certificate issuance failed`,
|
||||
auto_renew: 1,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
msg: msg || `Certificate issuance failed`,
|
||||
stringRes: hostname.value,
|
||||
singleRes: await grabCertificateByHostname({
|
||||
hostname: hostname.value,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
Authorization,
|
||||
Client,
|
||||
Order,
|
||||
} from "acme-client";
|
||||
import type { Challenge } from "acme-client/types/rfc8555";
|
||||
|
||||
export type ManualDnsChallenge = {
|
||||
route_id: number;
|
||||
hostname: string;
|
||||
txt_name: string;
|
||||
txt_value: string;
|
||||
client: Client;
|
||||
order: Order;
|
||||
authz: Authorization;
|
||||
challenge: Challenge;
|
||||
staging: boolean;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
const CHALLENGE_TTL_MS = 30 * 60 * 1000;
|
||||
const challenges = new Map<number, ManualDnsChallenge>();
|
||||
|
||||
function clearExpired() {
|
||||
const cutoff = Date.now() - CHALLENGE_TTL_MS;
|
||||
|
||||
for (const [route_id, challenge] of challenges) {
|
||||
if (challenge.created_at < cutoff) {
|
||||
challenges.delete(route_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function storeManualDnsChallenge(challenge: ManualDnsChallenge) {
|
||||
clearExpired();
|
||||
challenges.set(challenge.route_id, challenge);
|
||||
}
|
||||
|
||||
export function grabManualDnsChallenge(route_id: number) {
|
||||
clearExpired();
|
||||
return challenges.get(route_id) || null;
|
||||
}
|
||||
|
||||
export function removeManualDnsChallenge(route_id: number) {
|
||||
challenges.delete(route_id);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import parseAcmeCredentials from "./parse-acme-credentials";
|
||||
|
||||
describe("parseAcmeCredentials", () => {
|
||||
test("parses KEY=VALUE, export and comments", () => {
|
||||
const res = parseAcmeCredentials({
|
||||
text: [
|
||||
"# provider credentials",
|
||||
"CF_Token=abc123",
|
||||
"export CF_Account_ID=456",
|
||||
'CF_Zone="example.com"',
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.success && res.values).toEqual({
|
||||
CF_Token: "abc123",
|
||||
CF_Account_ID: "456",
|
||||
CF_Zone: "example.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects malformed lines", () => {
|
||||
expect(
|
||||
parseAcmeCredentials({ text: "CF_Token=abc\nnot a credential" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
type Params = {
|
||||
text?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the route form's KEY=VALUE credential text into env values.
|
||||
* Accepts `export KEY=VALUE`, quotes and `#` comments.
|
||||
*/
|
||||
export default function parseAcmeCredentials({ text }: Params) {
|
||||
const values: Record<string, string> = {};
|
||||
const lines = (text || "").split(/\r?\n/);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]?.trim();
|
||||
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(
|
||||
/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/,
|
||||
);
|
||||
|
||||
if (!match?.[1] || !KEY_PATTERN.test(match[1])) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Invalid credential line ${i + 1}`,
|
||||
values: {},
|
||||
};
|
||||
}
|
||||
|
||||
let value = (match[2] || "").trim();
|
||||
|
||||
if (!value.startsWith('"') && !value.startsWith("'")) {
|
||||
const inline_comment = value.indexOf(" #");
|
||||
|
||||
if (inline_comment > -1) {
|
||||
value = value.slice(0, inline_comment).trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
values[match[1]] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
values,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
|
||||
const { WGUI_ACME_ENV_FILE } = grabDirNames();
|
||||
|
||||
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export type AcmeEnv = {
|
||||
values: Record<string, string>;
|
||||
keys: string[];
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse /etc/wgui/acme.env without sourcing it through a shell. Provider
|
||||
* credentials stay in this object and are only passed to acme.sh via env.
|
||||
*/
|
||||
export default function readAcmeEnv(): AcmeEnv {
|
||||
if (!existsSync(WGUI_ACME_ENV_FILE)) {
|
||||
return { values: {}, keys: [], exists: false };
|
||||
}
|
||||
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const lines = readFileSync(WGUI_ACME_ENV_FILE, "utf-8").split(/\r?\n/);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]?.trim();
|
||||
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(
|
||||
/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/,
|
||||
);
|
||||
|
||||
if (!match?.[1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1];
|
||||
let value = (match[2] || "").trim();
|
||||
|
||||
if (!KEY_PATTERN.test(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!value.startsWith('"') && !value.startsWith("'")) {
|
||||
const inline_comment = value.indexOf(" #");
|
||||
|
||||
if (inline_comment > -1) {
|
||||
value = value.slice(0, inline_comment).trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
values[key] = value;
|
||||
}
|
||||
} catch {
|
||||
return { values: {}, keys: [], exists: true };
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
keys: Object.keys(values).sort(),
|
||||
exists: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
type Params = {
|
||||
cert_path?: string | null;
|
||||
};
|
||||
|
||||
export default function readCertificateExpiry({ cert_path }: Params) {
|
||||
if (!cert_path || !existsSync(cert_path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", cert_path, "-noout", "-enddate"],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 10000,
|
||||
},
|
||||
).trim();
|
||||
|
||||
const match = output.match(/notAfter=(.+)$/m);
|
||||
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(match[1]);
|
||||
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
|
||||
import { TlsData } from "@/src/data/tls-data";
|
||||
import { decrypt } from "@/src/utils/crypt";
|
||||
import readAcmeEnv from "./read-acme-env";
|
||||
|
||||
type Params = {
|
||||
route?: BUN_SQLITE_WGUI_CLIENT_ROUTES | null;
|
||||
};
|
||||
|
||||
export type RouteAcmeConfig = {
|
||||
provider: string;
|
||||
email: string;
|
||||
staging: boolean;
|
||||
values: Record<string, string>;
|
||||
source: "route" | "env";
|
||||
};
|
||||
|
||||
export default async function readRouteAcmeConfig({
|
||||
route,
|
||||
}: Params): Promise<
|
||||
| { success: true; config: RouteAcmeConfig }
|
||||
| { success: false; msg: string }
|
||||
> {
|
||||
const acme_env = readAcmeEnv();
|
||||
|
||||
let route_values: Record<string, string> = {};
|
||||
|
||||
if (route?.acme_credentials) {
|
||||
try {
|
||||
const decrypted = await decrypt(route.acme_credentials);
|
||||
const parsed = JSON.parse(decrypted) as Record<string, string>;
|
||||
route_values = parsed || {};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Could not decrypt the saved ACME credentials for this route`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const provider =
|
||||
route?.acme_dns_provider?.trim() ||
|
||||
acme_env.values.ACME_DNS_PROVIDER ||
|
||||
TlsData.DefaultDnsProvider;
|
||||
const email =
|
||||
route?.acme_email?.trim() || acme_env.values.ACME_EMAIL || "";
|
||||
const source = route?.acme_dns_provider ? "route" : "env";
|
||||
const values =
|
||||
source == "route" ? route_values : acme_env.values;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
config: {
|
||||
provider,
|
||||
email,
|
||||
staging: route?.acme_staging == 1 || TlsData.AcmeStaging,
|
||||
values,
|
||||
source,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { BUN_SQLITE_WGUI_TLS_CERTIFICATES } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { TableType } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
certificate: BUN_SQLITE_WGUI_TLS_CERTIFICATES;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upsert by hostname. `update_on_duplicate` on a non-id unique column is not
|
||||
* reliable across SQLite builds, so resolve the existing row explicitly.
|
||||
*/
|
||||
export default async function recordCertificate({ certificate }: Params) {
|
||||
if (!certificate.hostname) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Certificate hostname is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existing_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
TableType
|
||||
>({
|
||||
table: "tls_certificates",
|
||||
query: {
|
||||
query: {
|
||||
hostname: {
|
||||
value: certificate.hostname,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const existing = existing_res.singleRes;
|
||||
|
||||
if (existing?.id) {
|
||||
return await BunSQLite.update<
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
TableType
|
||||
>({
|
||||
table: "tls_certificates",
|
||||
targetId: existing.id,
|
||||
data: certificate,
|
||||
});
|
||||
}
|
||||
|
||||
return await BunSQLite.insert<
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
TableType
|
||||
>({
|
||||
table: "tls_certificates",
|
||||
data: [certificate],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
type Params = {
|
||||
text?: string | null;
|
||||
secrets: string[];
|
||||
};
|
||||
|
||||
export default function redactSecrets({ text, secrets }: Params) {
|
||||
let output = text || "";
|
||||
|
||||
for (let i = 0; i < secrets.length; i++) {
|
||||
const secret = secrets[i];
|
||||
|
||||
if (secret && secret.length >= 4) {
|
||||
output = output.split(secret).join(`[redacted]`);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { encrypt } from "@/src/utils/crypt";
|
||||
import parseAcmeCredentials from "./parse-acme-credentials";
|
||||
|
||||
type Params = {
|
||||
/** Plaintext from the route form. Empty keeps the existing value. */
|
||||
text?: string | null;
|
||||
/** Existing encrypted value from the database. */
|
||||
existing?: string | null;
|
||||
};
|
||||
|
||||
export default async function sealRouteCredentials({
|
||||
text,
|
||||
existing,
|
||||
}: Params) {
|
||||
if (!text?.trim()) {
|
||||
return {
|
||||
success: true as const,
|
||||
value: existing || "",
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseAcmeCredentials({ text });
|
||||
|
||||
if (!parsed.success) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (!Object.keys(parsed.values).length) {
|
||||
return {
|
||||
success: true as const,
|
||||
value: existing || "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
value: await encrypt(JSON.stringify(parsed.values)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import * as acme from "acme-client";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { TableType } from "@/src/types";
|
||||
import validateRouteHostname from "@/src/functions/backend/caddy/validate-route-hostname";
|
||||
import grabAcmeAccountKey from "./grab-acme-account-key";
|
||||
import {
|
||||
grabManualDnsChallenge,
|
||||
removeManualDnsChallenge,
|
||||
storeManualDnsChallenge,
|
||||
} from "./manual-dns-challenges";
|
||||
|
||||
type Params = {
|
||||
route_id?: number | "";
|
||||
user_id?: number | "";
|
||||
};
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export default async function startManualCertificate({
|
||||
route_id,
|
||||
user_id,
|
||||
}: Params): Promise<APIResponseObject> {
|
||||
const final_route_id = Number(route_id);
|
||||
|
||||
if (!Number.isSafeInteger(final_route_id) || final_route_id < 1) {
|
||||
return { success: false, msg: `Invalid route` };
|
||||
}
|
||||
|
||||
const route_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
targetId: final_route_id,
|
||||
});
|
||||
|
||||
const route = route_res.singleRes;
|
||||
|
||||
if (!route) {
|
||||
return { success: false, msg: `Route not found` };
|
||||
}
|
||||
|
||||
const hostname = validateRouteHostname({ value: route.hostname });
|
||||
|
||||
if (!hostname.success) {
|
||||
return { success: false, msg: hostname.msg };
|
||||
}
|
||||
|
||||
const email = (route.acme_email || "").trim();
|
||||
|
||||
if (!email || !EMAIL_PATTERN.test(email)) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Set a valid ACME email on the route form first`,
|
||||
};
|
||||
}
|
||||
|
||||
const staging = route.acme_staging == 1;
|
||||
const directory_url = staging
|
||||
? acme.directory.letsencrypt.staging
|
||||
: acme.directory.letsencrypt.production;
|
||||
|
||||
try {
|
||||
const previous = grabManualDnsChallenge(final_route_id);
|
||||
|
||||
if (previous) {
|
||||
try {
|
||||
await previous.client.deactivateAuthorization(previous.authz);
|
||||
} catch {
|
||||
// The previous authorization may already be invalid/expired.
|
||||
}
|
||||
|
||||
removeManualDnsChallenge(final_route_id);
|
||||
}
|
||||
|
||||
const account_key = await grabAcmeAccountKey();
|
||||
const client = new acme.Client({
|
||||
directoryUrl: directory_url,
|
||||
accountKey: account_key,
|
||||
});
|
||||
|
||||
await client.createAccount({
|
||||
termsOfServiceAgreed: true,
|
||||
contact: [`mailto:${email}`],
|
||||
});
|
||||
|
||||
const order = await client.createOrder({
|
||||
identifiers: [{ type: "dns", value: hostname.value }],
|
||||
});
|
||||
|
||||
const authorizations = await client.getAuthorizations(order);
|
||||
const authz = authorizations[0];
|
||||
|
||||
if (!authz) {
|
||||
throw new Error(`The CA did not return an authorization`);
|
||||
}
|
||||
|
||||
const challenge = authz.challenges.find(
|
||||
(item) => item.type == "dns-01",
|
||||
);
|
||||
|
||||
if (!challenge) {
|
||||
throw new Error(`The CA did not offer a dns-01 challenge`);
|
||||
}
|
||||
|
||||
const txt_value = await client.getChallengeKeyAuthorization(challenge);
|
||||
const txt_name = `_acme-challenge.${hostname.value}`;
|
||||
|
||||
storeManualDnsChallenge({
|
||||
route_id: final_route_id,
|
||||
hostname: hostname.value,
|
||||
txt_name,
|
||||
txt_value,
|
||||
client,
|
||||
order,
|
||||
authz,
|
||||
challenge,
|
||||
staging,
|
||||
created_at: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Add the TXT record at your DNS registrar, then click Validate.`,
|
||||
singleRes: {
|
||||
txt_name,
|
||||
txt_value,
|
||||
hostname: hostname.value,
|
||||
staging,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message || `Could not start certificate issuance`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { TlsData } from "@/src/data/tls-data";
|
||||
import deriveWireguardInterfaceName from "@/src/utils/derive-wireguard-interface-name";
|
||||
import deriveIptablesChainNames from "../setup/derive-iptables-chain-names";
|
||||
|
||||
type Params = {
|
||||
host_id: number;
|
||||
port?: number;
|
||||
};
|
||||
|
||||
function iptables({ args }: { args: string[] }) {
|
||||
try {
|
||||
execFileSync("iptables", args, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow clients of a host to reach the Caddy ingress over the tunnel.
|
||||
*
|
||||
* The host's generated INPUT chain ends in DROP, so the port must be accepted
|
||||
* explicitly. This applies the rule immediately for already-running tunnels;
|
||||
* `buildHostIptablesScripts` also writes it into the next post_up.
|
||||
*/
|
||||
export default function applyCaddyIngressFirewallRule({
|
||||
host_id,
|
||||
port,
|
||||
}: Params) {
|
||||
const final_port = port || TlsData.CaddyHttpsPort;
|
||||
|
||||
if (
|
||||
!Number.isSafeInteger(host_id) ||
|
||||
host_id < 0 ||
|
||||
!Number.isInteger(final_port) ||
|
||||
final_port < 1 ||
|
||||
final_port > 65535
|
||||
) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Invalid host or ingress port`,
|
||||
};
|
||||
}
|
||||
|
||||
const interface_name = deriveWireguardInterfaceName({ host_id });
|
||||
const { input } = deriveIptablesChainNames({ host_id });
|
||||
|
||||
// The chain only exists while the tunnel is up. The next host setup will
|
||||
// regenerate the script and include the ingress rule.
|
||||
if (!iptables({ args: ["-L", input, "-n"] })) {
|
||||
return {
|
||||
success: true as const,
|
||||
msg: ``,
|
||||
};
|
||||
}
|
||||
|
||||
const rule_args = [
|
||||
"-i",
|
||||
interface_name,
|
||||
"-p",
|
||||
"tcp",
|
||||
"--dport",
|
||||
String(final_port),
|
||||
"-j",
|
||||
"ACCEPT",
|
||||
];
|
||||
|
||||
if (iptables({ args: ["-C", input, ...rule_args] })) {
|
||||
return {
|
||||
success: true as const,
|
||||
msg: ``,
|
||||
};
|
||||
}
|
||||
|
||||
const added = iptables({
|
||||
args: ["-I", input, "1", ...rule_args],
|
||||
});
|
||||
|
||||
return added
|
||||
? {
|
||||
success: true as const,
|
||||
msg: ``,
|
||||
}
|
||||
: {
|
||||
success: false as const,
|
||||
msg: `Could not allow port ${final_port}/tcp on ${interface_name}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
} from "@/db/types/db";
|
||||
import buildCaddyfile from "./build-caddyfile";
|
||||
|
||||
const base = {
|
||||
https_port: 8443,
|
||||
http_port: 8081,
|
||||
admin_socket: `/run/wgui-caddy/admin.sock`,
|
||||
storage_dir: `/var/lib/wgui/caddy/data`,
|
||||
};
|
||||
|
||||
const certificate = {
|
||||
hostname: "media.example.com",
|
||||
cert_path: "/var/lib/wgui/tls/media.example.com/fullchain.pem",
|
||||
key_path: "/var/lib/wgui/tls/media.example.com/privkey.pem",
|
||||
status: "active",
|
||||
} as BUN_SQLITE_WGUI_TLS_CERTIFICATES;
|
||||
|
||||
function route(
|
||||
values: Partial<BUN_SQLITE_WGUI_CLIENT_ROUTES>,
|
||||
): BUN_SQLITE_WGUI_CLIENT_ROUTES {
|
||||
return {
|
||||
hostname: "media.example.com",
|
||||
path_prefix: "/",
|
||||
upstream_url: "http://10.5.0.20:8080",
|
||||
enabled: 1,
|
||||
...values,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildCaddyfile", () => {
|
||||
test("uses a high port and never 80/443", () => {
|
||||
const res = buildCaddyfile({ ...base, routes: [], certificates: [] });
|
||||
|
||||
expect(res.config).toContain("https_port 8443");
|
||||
expect(res.config).toContain("http_port 8081");
|
||||
expect(res.config).toContain("auto_https disable_redirects");
|
||||
expect(res.config).not.toContain("https_port 443");
|
||||
expect(res.config).not.toContain("http_port 80\n");
|
||||
});
|
||||
|
||||
test("orders longer prefixes before the catch-all", () => {
|
||||
const res = buildCaddyfile({
|
||||
...base,
|
||||
certificates: [certificate],
|
||||
routes: [
|
||||
route({ name: "root", path_prefix: "/" }),
|
||||
route({
|
||||
name: "app",
|
||||
path_prefix: "/app",
|
||||
strip_prefix: 1,
|
||||
upstream_url: "http://10.5.0.30:9000",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const app_index = res.config?.indexOf("@route_1 {") || 0;
|
||||
const root_index = res.config?.indexOf("handle {") || 0;
|
||||
|
||||
expect(app_index).toBeGreaterThan(0);
|
||||
expect(root_index).toBeGreaterThan(app_index);
|
||||
expect(res.config).toContain("path /app /app/*");
|
||||
expect(res.config).toContain("uri strip_prefix /app");
|
||||
});
|
||||
|
||||
test("skips routes without an active certificate", () => {
|
||||
const res = buildCaddyfile({
|
||||
...base,
|
||||
certificates: [],
|
||||
routes: [route({ name: "media" })],
|
||||
});
|
||||
|
||||
expect(res.config).not.toContain("media.example.com {");
|
||||
expect(res.warnings[0]).toContain("certificate");
|
||||
});
|
||||
|
||||
test("scopes a route to its host's WireGuard subnet", () => {
|
||||
const res = buildCaddyfile({
|
||||
...base,
|
||||
certificates: [certificate],
|
||||
hosts: [{ id: 0, wg_ip_address: "10.5.0.1" }],
|
||||
routes: [route({ host_id: 0, path_prefix: "/app" })],
|
||||
});
|
||||
|
||||
expect(res.config).toContain("remote_ip 10.5.0.0/24");
|
||||
});
|
||||
|
||||
test("maps insecure upstream TLS to Caddy", () => {
|
||||
const res = buildCaddyfile({
|
||||
...base,
|
||||
certificates: [certificate],
|
||||
routes: [
|
||||
route({
|
||||
upstream_url: "https://10.5.0.20:8443",
|
||||
upstream_tls_verify: 0,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.config).toContain("tls_insecure_skip_verify");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
} from "@/db/types/db";
|
||||
import validateRouteHostname from "./validate-route-hostname";
|
||||
import validateRoutePath from "./validate-route-path";
|
||||
import validateUpstreamUrl from "./validate-upstream-url";
|
||||
|
||||
export type CaddyBuildResult = {
|
||||
success: boolean;
|
||||
msg?: string;
|
||||
config?: string;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type Params = {
|
||||
routes?: BUN_SQLITE_WGUI_CLIENT_ROUTES[];
|
||||
certificates?: BUN_SQLITE_WGUI_TLS_CERTIFICATES[];
|
||||
hosts?: BUN_SQLITE_WGUI_HOSTS[];
|
||||
https_port: number;
|
||||
http_port: number;
|
||||
admin_socket: string;
|
||||
storage_dir: string;
|
||||
};
|
||||
|
||||
function reverseProxyBlock({
|
||||
route,
|
||||
indent,
|
||||
}: {
|
||||
route: BUN_SQLITE_WGUI_CLIENT_ROUTES;
|
||||
indent: string;
|
||||
}) {
|
||||
const upstream = route.upstream_url || "";
|
||||
const lines: string[] = [];
|
||||
|
||||
if (upstream.startsWith("https:") && route.upstream_tls_verify == 0) {
|
||||
lines.push(`${indent}reverse_proxy ${upstream} {`);
|
||||
lines.push(`${indent} transport http {`);
|
||||
lines.push(`${indent} tls_insecure_skip_verify`);
|
||||
lines.push(`${indent} }`);
|
||||
lines.push(`${indent}}`);
|
||||
} else {
|
||||
lines.push(`${indent}reverse_proxy ${upstream}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function routeBlock({
|
||||
route,
|
||||
index,
|
||||
subnet,
|
||||
}: {
|
||||
route: BUN_SQLITE_WGUI_CLIENT_ROUTES;
|
||||
index: number;
|
||||
subnet?: string;
|
||||
}) {
|
||||
const path_prefix = route.path_prefix || "/";
|
||||
const matcher = `route_${index}`;
|
||||
const lines: string[] = [];
|
||||
|
||||
if (path_prefix == "/" && !subnet) {
|
||||
lines.push(` handle {`);
|
||||
lines.push(...reverseProxyBlock({ route, indent: " " }));
|
||||
lines.push(` }`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (path_prefix == "/") {
|
||||
lines.push(
|
||||
` @${matcher} remote_ip ${subnet} 127.0.0.1/32 ::1/128`,
|
||||
);
|
||||
} else {
|
||||
lines.push(` @${matcher} {`);
|
||||
lines.push(` path ${path_prefix} ${path_prefix}/*`);
|
||||
|
||||
if (subnet) {
|
||||
lines.push(
|
||||
` remote_ip ${subnet} 127.0.0.1/32 ::1/128`,
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(` }`);
|
||||
}
|
||||
|
||||
lines.push(` handle @${matcher} {`);
|
||||
|
||||
if (path_prefix != "/" && route.strip_prefix == 1) {
|
||||
lines.push(` uri strip_prefix ${path_prefix}`);
|
||||
}
|
||||
|
||||
lines.push(...reverseProxyBlock({ route, indent: " " }));
|
||||
lines.push(` }`);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Caddyfile for the enabled routes. Routes without an active
|
||||
* certificate are skipped and reported as warnings instead of being served
|
||||
* with an untrusted fallback certificate.
|
||||
*/
|
||||
export default function buildCaddyfile({
|
||||
routes,
|
||||
certificates,
|
||||
hosts,
|
||||
https_port,
|
||||
http_port,
|
||||
admin_socket,
|
||||
storage_dir,
|
||||
}: Params): CaddyBuildResult {
|
||||
const warnings: string[] = [];
|
||||
|
||||
const subnet_by_host_id = new Map<number, string>();
|
||||
|
||||
for (let i = 0; i < (hosts || []).length; i++) {
|
||||
const host = hosts?.[i];
|
||||
const parts = (host?.wg_ip_address || "").trim().split(".");
|
||||
|
||||
if (
|
||||
host?.id !== undefined &&
|
||||
parts.length == 4 &&
|
||||
parts.every((part) => /^\d{1,3}$/.test(part))
|
||||
) {
|
||||
subnet_by_host_id.set(
|
||||
Number(host.id),
|
||||
`${parts.slice(0, 3).join(".")}.0/24`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const certificates_by_hostname = new Map<
|
||||
string,
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES
|
||||
>();
|
||||
|
||||
for (let i = 0; i < (certificates || []).length; i++) {
|
||||
const certificate = certificates?.[i];
|
||||
const hostname = certificate?.hostname?.trim().toLowerCase();
|
||||
|
||||
if (
|
||||
hostname &&
|
||||
certificate?.cert_path &&
|
||||
certificate.key_path &&
|
||||
(certificate.status == "active" ||
|
||||
certificate.status == "expiring")
|
||||
) {
|
||||
certificates_by_hostname.set(hostname, {
|
||||
...certificate,
|
||||
hostname,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const routes_by_hostname = new Map<
|
||||
string,
|
||||
{
|
||||
route: BUN_SQLITE_WGUI_CLIENT_ROUTES;
|
||||
subnet?: string;
|
||||
}[]
|
||||
>();
|
||||
|
||||
for (let i = 0; i < (routes || []).length; i++) {
|
||||
const route = routes?.[i];
|
||||
|
||||
if (!route?.hostname || !route.upstream_url || route.enabled == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostname = validateRouteHostname({ value: route.hostname });
|
||||
const path_prefix = validateRoutePath({
|
||||
value: route.path_prefix,
|
||||
});
|
||||
const upstream = validateUpstreamUrl({
|
||||
value: route.upstream_url,
|
||||
});
|
||||
|
||||
if (!hostname.success || !path_prefix.success || !upstream.success) {
|
||||
warnings.push(
|
||||
`Route ${route.name || route.hostname} has invalid settings and was skipped`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!certificates_by_hostname.has(hostname.value)) {
|
||||
warnings.push(
|
||||
`Route ${route.name || hostname.value} is skipped until its certificate is issued`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const safe_hostname = hostname.value || "";
|
||||
const safe_route: BUN_SQLITE_WGUI_CLIENT_ROUTES = {
|
||||
...route,
|
||||
hostname: safe_hostname,
|
||||
path_prefix: path_prefix.value || "/",
|
||||
upstream_url: upstream.value || "",
|
||||
};
|
||||
|
||||
const subnet = subnet_by_host_id.get(Number(route.host_id || 0));
|
||||
|
||||
if (hosts?.length && !subnet) {
|
||||
warnings.push(
|
||||
`Route ${route.name || safe_hostname} is skipped because its host has no WireGuard address`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = routes_by_hostname.get(safe_hostname) || [];
|
||||
existing.push({ route: safe_route, subnet });
|
||||
routes_by_hostname.set(safe_hostname, existing);
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`{`,
|
||||
` https_port ${https_port}`,
|
||||
` http_port ${http_port}`,
|
||||
` auto_https disable_redirects`,
|
||||
` admin unix/${admin_socket}`,
|
||||
` storage file_system ${storage_dir}`,
|
||||
`}`,
|
||||
``,
|
||||
];
|
||||
|
||||
const hostnames = [...routes_by_hostname.keys()].sort();
|
||||
|
||||
for (let h = 0; h < hostnames.length; h++) {
|
||||
const hostname = hostnames[h];
|
||||
|
||||
if (!hostname) continue;
|
||||
|
||||
const certificate = certificates_by_hostname.get(hostname);
|
||||
const host_routes = routes_by_hostname.get(hostname) || [];
|
||||
|
||||
if (!certificate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
host_routes.sort((a, b) => {
|
||||
const a_path = a.route.path_prefix || "/";
|
||||
const b_path = b.route.path_prefix || "/";
|
||||
|
||||
if (a_path == "/") return 1;
|
||||
if (b_path == "/") return -1;
|
||||
|
||||
return b_path.length - a_path.length;
|
||||
});
|
||||
|
||||
lines.push(`${hostname} {`);
|
||||
lines.push(
|
||||
` tls "${certificate.cert_path}" "${certificate.key_path}"`,
|
||||
);
|
||||
|
||||
for (let r = 0; r < host_routes.length; r++) {
|
||||
const entry = host_routes[r];
|
||||
|
||||
if (!entry) continue;
|
||||
|
||||
lines.push(
|
||||
...routeBlock({
|
||||
route: entry.route,
|
||||
subnet: entry.subnet,
|
||||
index: r + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
config: lines.join("\n"),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
import { TlsData } from "@/src/data/tls-data";
|
||||
import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status";
|
||||
|
||||
const {
|
||||
WGUI_CADDY_BINARY,
|
||||
WGUI_LIB_CADDY_CONFIG_FILE,
|
||||
WGUI_LIB_CADDY_STATE_FILE,
|
||||
WGUI_ACME_SCRIPT,
|
||||
WGUI_ACME_ENV_FILE,
|
||||
} = grabDirNames();
|
||||
|
||||
function readState() {
|
||||
try {
|
||||
if (!existsSync(WGUI_LIB_CADDY_STATE_FILE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(
|
||||
readFileSync(WGUI_LIB_CADDY_STATE_FILE, "utf-8"),
|
||||
) as {
|
||||
last_reload_at?: number;
|
||||
last_error?: string | null;
|
||||
warnings?: string[];
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function version() {
|
||||
try {
|
||||
return execFileSync(WGUI_CADDY_BINARY, ["version"], {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serviceActive() {
|
||||
const setup_status = grabSystemSetupStatus();
|
||||
|
||||
try {
|
||||
if (setup_status.init_system == "systemd") {
|
||||
return (
|
||||
execFileSync(
|
||||
"systemctl",
|
||||
["is-active", "wgui-caddy"],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
},
|
||||
).trim() == "active"
|
||||
);
|
||||
}
|
||||
|
||||
if (setup_status.init_system == "openrc") {
|
||||
execFileSync("rc-service", ["wgui-caddy", "status"], {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function grabCaddyStatus() {
|
||||
const state = readState();
|
||||
|
||||
return {
|
||||
installed: existsSync(WGUI_CADDY_BINARY),
|
||||
service_active: serviceActive(),
|
||||
acme_installed: existsSync(WGUI_ACME_SCRIPT),
|
||||
acme_env_exists: existsSync(WGUI_ACME_ENV_FILE),
|
||||
version: version(),
|
||||
https_port: TlsData.CaddyHttpsPort,
|
||||
http_port: TlsData.CaddyHttpPort,
|
||||
enabled: TlsData.IngressEnabled,
|
||||
config_path: WGUI_LIB_CADDY_CONFIG_FILE,
|
||||
state,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import path from "node:path";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
|
||||
type Params = {
|
||||
hostname: string;
|
||||
};
|
||||
|
||||
export default function grabCertDirNames({ hostname }: Params) {
|
||||
const { WGUI_LIB_TLS_DIR } = grabDirNames();
|
||||
const CERT_DIR = path.join(WGUI_LIB_TLS_DIR, hostname);
|
||||
|
||||
return {
|
||||
CERT_DIR,
|
||||
CERT_FILE: path.join(CERT_DIR, "fullchain.pem"),
|
||||
KEY_FILE: path.join(CERT_DIR, "privkey.pem"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status";
|
||||
|
||||
const CADDY_SERVICE_NAME = "wgui-caddy";
|
||||
|
||||
function run({ command, args }: { command: string; args: string[] }) {
|
||||
try {
|
||||
const output = execFileSync(command, args, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 30000,
|
||||
}).trim();
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
msg: output,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const stdout = error?.stdout ? String(error.stdout).trim() : "";
|
||||
const stderr = error?.stderr ? String(error.stderr).trim() : "";
|
||||
|
||||
return {
|
||||
success: false as const,
|
||||
msg: [stdout, stderr, error.message]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function caddyServiceIsActive({
|
||||
init_system,
|
||||
}: {
|
||||
init_system: ReturnType<typeof grabSystemSetupStatus>["init_system"];
|
||||
}) {
|
||||
if (init_system == "systemd") {
|
||||
const status = run({
|
||||
command: "systemctl",
|
||||
args: ["is-active", CADDY_SERVICE_NAME],
|
||||
});
|
||||
|
||||
return status.success && status.msg == "active";
|
||||
}
|
||||
|
||||
if (init_system == "openrc") {
|
||||
return run({
|
||||
command: "rc-service",
|
||||
args: [CADDY_SERVICE_NAME, "status"],
|
||||
}).success;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function reloadCaddyService() {
|
||||
const setup_status = grabSystemSetupStatus();
|
||||
const service_active = caddyServiceIsActive({
|
||||
init_system: setup_status.init_system,
|
||||
});
|
||||
|
||||
if (setup_status.is_dev && !service_active) {
|
||||
return {
|
||||
success: true as const,
|
||||
msg: `Development mode — Caddy service is not running, reload skipped`,
|
||||
};
|
||||
}
|
||||
|
||||
switch (setup_status.init_system) {
|
||||
case "systemd":
|
||||
return run({
|
||||
command: "systemctl",
|
||||
args: ["reload", CADDY_SERVICE_NAME],
|
||||
});
|
||||
case "openrc":
|
||||
return run({
|
||||
command: "rc-service",
|
||||
args: [CADDY_SERVICE_NAME, "reload"],
|
||||
});
|
||||
default:
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `No supported init system found for wgui-caddy`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
|
||||
import validateRouteHostname from "./validate-route-hostname";
|
||||
import validateRoutePath from "./validate-route-path";
|
||||
import validateUpstreamUrl from "./validate-upstream-url";
|
||||
|
||||
const PROVIDER_PATTERN = /^dns_[a-z0-9_]+$/;
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
type Params = {
|
||||
route?: BUN_SQLITE_WGUI_CLIENT_ROUTES | null;
|
||||
user_id?: number | "";
|
||||
host_id?: number | "";
|
||||
};
|
||||
|
||||
export default function sanitizeClientRoute({
|
||||
route,
|
||||
user_id,
|
||||
host_id,
|
||||
}: Params) {
|
||||
if (!route) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `No route provided`,
|
||||
};
|
||||
}
|
||||
|
||||
const hostname = validateRouteHostname({ value: route.hostname });
|
||||
|
||||
if (!hostname.success) {
|
||||
return hostname;
|
||||
}
|
||||
|
||||
const path_prefix = validateRoutePath({ value: route.path_prefix });
|
||||
|
||||
if (!path_prefix.success) {
|
||||
return path_prefix;
|
||||
}
|
||||
|
||||
const upstream = validateUpstreamUrl({ value: route.upstream_url });
|
||||
|
||||
if (!upstream.success) {
|
||||
return upstream;
|
||||
}
|
||||
|
||||
const final_host_id = Number(route.host_id ?? host_id ?? 0);
|
||||
|
||||
if (!Number.isSafeInteger(final_host_id) || final_host_id < 0) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Invalid host`,
|
||||
};
|
||||
}
|
||||
|
||||
const acme_provider =
|
||||
typeof route.acme_dns_provider == "string"
|
||||
? route.acme_dns_provider.trim()
|
||||
: "";
|
||||
const acme_email =
|
||||
typeof route.acme_email == "string" ? route.acme_email.trim() : "";
|
||||
|
||||
if (acme_provider && !PROVIDER_PATTERN.test(acme_provider)) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Invalid DNS provider "${acme_provider}"`,
|
||||
};
|
||||
}
|
||||
|
||||
if (acme_email && !EMAIL_PATTERN.test(acme_email)) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Invalid ACME email "${acme_email}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
route: {
|
||||
...route,
|
||||
user_id: route.user_id || user_id,
|
||||
host_id: final_host_id,
|
||||
name: (route.name || hostname.value).trim().slice(0, 120),
|
||||
hostname: hostname.value,
|
||||
path_prefix: path_prefix.value,
|
||||
strip_prefix: route.strip_prefix == 1 ? 1 : 0,
|
||||
upstream_url: upstream.value,
|
||||
upstream_tls_verify: route.upstream_tls_verify == 0 ? 0 : 1,
|
||||
enabled: route.enabled == 0 ? 0 : 1,
|
||||
notes: (route.notes || "").trim(),
|
||||
acme_email,
|
||||
acme_dns_provider: acme_provider,
|
||||
acme_staging: route.acme_staging == 1 ? 1 : 0,
|
||||
acme_credentials:
|
||||
typeof route.acme_credentials == "string"
|
||||
? route.acme_credentials
|
||||
: "",
|
||||
} satisfies BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
} from "@/db/types/db";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { TableType } from "@/src/types";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
import { TlsData } from "@/src/data/tls-data";
|
||||
import buildCaddyfile from "./build-caddyfile";
|
||||
import reloadCaddyService from "./reload-caddy-service";
|
||||
import applyCaddyIngressFirewallRule from "./apply-caddy-ingress-firewall-rule";
|
||||
|
||||
export type CaddySyncResult = {
|
||||
success: boolean;
|
||||
msg?: string;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type Params = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
const {
|
||||
WGUI_CADDY_BINARY,
|
||||
WGUI_LIB_CADDY_DIR,
|
||||
WGUI_LIB_CADDY_CONFIG_FILE,
|
||||
WGUI_LIB_CADDY_STATE_FILE,
|
||||
WGUI_LIB_CADDY_DATA_DIR,
|
||||
WGUI_LIB_CADDY_ADMIN_SOCKET,
|
||||
} = grabDirNames();
|
||||
|
||||
let sync_chain: Promise<CaddySyncResult> = Promise.resolve({
|
||||
success: true,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
function writeState({
|
||||
warnings,
|
||||
last_error,
|
||||
}: {
|
||||
warnings: string[];
|
||||
last_error?: string | null;
|
||||
}) {
|
||||
try {
|
||||
writeFileSync(
|
||||
WGUI_LIB_CADDY_STATE_FILE,
|
||||
JSON.stringify(
|
||||
{
|
||||
last_reload_at: Date.now(),
|
||||
last_error: last_error || null,
|
||||
warnings,
|
||||
},
|
||||
null,
|
||||
4,
|
||||
),
|
||||
{ mode: 0o644 },
|
||||
);
|
||||
} catch {
|
||||
// The config is still valid; state is only for the setup page.
|
||||
}
|
||||
}
|
||||
|
||||
async function doSync({ reason }: Params): Promise<CaddySyncResult> {
|
||||
if (!TlsData.IngressEnabled) {
|
||||
return {
|
||||
success: true,
|
||||
warnings: [],
|
||||
msg: `Caddy ingress is disabled`,
|
||||
};
|
||||
}
|
||||
|
||||
const routes_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
});
|
||||
|
||||
const certificates_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
TableType
|
||||
>({
|
||||
table: "tls_certificates",
|
||||
});
|
||||
|
||||
const hosts_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
|
||||
table: "hosts",
|
||||
});
|
||||
|
||||
const routes = routes_res.payload || [];
|
||||
const hosts = hosts_res.payload || [];
|
||||
const certificates = (certificates_res.payload || [])
|
||||
.filter(
|
||||
(certificate) =>
|
||||
Boolean(
|
||||
certificate.cert_path &&
|
||||
certificate.key_path &&
|
||||
existsSync(certificate.cert_path) &&
|
||||
existsSync(certificate.key_path),
|
||||
),
|
||||
)
|
||||
.map((certificate) => ({
|
||||
...certificate,
|
||||
// A valid file on disk is enough to serve; the setup page
|
||||
// separately reports expiry and errors.
|
||||
status:
|
||||
certificate.status == "issuing"
|
||||
? ("issuing" as const)
|
||||
: ("active" as const),
|
||||
}));
|
||||
|
||||
const built = buildCaddyfile({
|
||||
routes,
|
||||
certificates,
|
||||
hosts,
|
||||
https_port: TlsData.CaddyHttpsPort,
|
||||
http_port: TlsData.CaddyHttpPort,
|
||||
admin_socket: WGUI_LIB_CADDY_ADMIN_SOCKET,
|
||||
storage_dir: WGUI_LIB_CADDY_DATA_DIR,
|
||||
});
|
||||
|
||||
if (!built.success || !built.config) {
|
||||
writeState({
|
||||
warnings: built.warnings,
|
||||
last_error: built.msg || `Could not build the Caddyfile`,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
msg: built.msg || `Could not build the Caddyfile`,
|
||||
warnings: built.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
mkdirSync(WGUI_LIB_CADDY_DIR, { recursive: true });
|
||||
mkdirSync(WGUI_LIB_CADDY_DATA_DIR, { recursive: true });
|
||||
|
||||
const tmp_path = path.join(WGUI_LIB_CADDY_DIR, `Caddyfile.tmp`);
|
||||
const backup_path = path.join(WGUI_LIB_CADDY_DIR, `Caddyfile.last-good`);
|
||||
|
||||
writeFileSync(tmp_path, built.config, { mode: 0o644 });
|
||||
|
||||
if (existsSync(WGUI_CADDY_BINARY)) {
|
||||
try {
|
||||
execFileSync(
|
||||
WGUI_CADDY_BINARY,
|
||||
[
|
||||
"validate",
|
||||
"--config",
|
||||
tmp_path,
|
||||
"--adapter",
|
||||
"caddyfile",
|
||||
],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
rmSync(tmp_path, { force: true });
|
||||
const msg = `Caddy rejected the generated config: ${
|
||||
error?.stderr
|
||||
? String(error.stderr).trim()
|
||||
: error.message
|
||||
}`;
|
||||
|
||||
writeState({ warnings: built.warnings, last_error: msg });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
msg,
|
||||
warnings: built.warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(WGUI_LIB_CADDY_CONFIG_FILE)) {
|
||||
copyFileSync(WGUI_LIB_CADDY_CONFIG_FILE, backup_path);
|
||||
}
|
||||
|
||||
renameSync(tmp_path, WGUI_LIB_CADDY_CONFIG_FILE);
|
||||
|
||||
const reload = reloadCaddyService();
|
||||
const warnings = [...built.warnings];
|
||||
|
||||
if (!reload.success) {
|
||||
warnings.push(`Caddy reload failed: ${reload.msg}`);
|
||||
}
|
||||
|
||||
writeState({
|
||||
warnings,
|
||||
last_error: reload.success ? null : reload.msg,
|
||||
});
|
||||
|
||||
const host_ids = [
|
||||
...new Set(
|
||||
routes
|
||||
.filter((route) => route.enabled != 0)
|
||||
.map((route) => Number(route.host_id || 0))
|
||||
.filter((host_id) => host_id >= 0),
|
||||
),
|
||||
];
|
||||
|
||||
for (let i = 0; i < host_ids.length; i++) {
|
||||
const host_id = host_ids[i];
|
||||
|
||||
if (typeof host_id != "number") continue;
|
||||
|
||||
const firewall = applyCaddyIngressFirewallRule({ host_id });
|
||||
|
||||
if (!firewall.success && firewall.msg) {
|
||||
warnings.push(firewall.msg);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: reload.success,
|
||||
msg: reload.success
|
||||
? `Caddy ingress synced (${reason || "manual"})`
|
||||
: reload.msg,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize config writes. Route/cert mutations can arrive concurrently while
|
||||
* Caddy is reloading; the last committed config must always be a complete one.
|
||||
*/
|
||||
export default function syncCaddyIngress(
|
||||
params?: Params,
|
||||
): Promise<CaddySyncResult> {
|
||||
const run = sync_chain.then(
|
||||
() => doSync(params || {}),
|
||||
() => doSync(params || {}),
|
||||
);
|
||||
|
||||
sync_chain = run.then(
|
||||
() => ({ success: true, warnings: [] }),
|
||||
() => ({ success: true, warnings: [] }),
|
||||
);
|
||||
|
||||
return run;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import validateRouteHostname from "./validate-route-hostname";
|
||||
|
||||
describe("validateRouteHostname", () => {
|
||||
test("accepts and lowercases a DNS name", () => {
|
||||
const res = validateRouteHostname({ value: "Media.Example.COM" });
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.success && res.value).toBe("media.example.com");
|
||||
});
|
||||
|
||||
test("rejects schemes, ports, wildcards and injection", () => {
|
||||
for (const value of [
|
||||
"https://media.example.com",
|
||||
"media.example.com:8443",
|
||||
"*.example.com",
|
||||
"media.example.com\nhandle",
|
||||
"media example.com",
|
||||
"",
|
||||
]) {
|
||||
expect(validateRouteHostname({ value }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const HOSTNAME_PATTERN =
|
||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])$/;
|
||||
|
||||
type Params = {
|
||||
value?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Route hostnames are used as Caddy site addresses and certificate names.
|
||||
* Keep them to plain lowercase DNS labels: no wildcards, ports, schemes,
|
||||
* spaces or Caddyfile control characters.
|
||||
*/
|
||||
export default function validateRouteHostname({ value }: Params) {
|
||||
const hostname = (value || "").trim().toLowerCase();
|
||||
|
||||
if (!hostname) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Hostname is required`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!HOSTNAME_PATTERN.test(hostname)) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Invalid hostname "${hostname}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
value: hostname,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import validateRoutePath from "./validate-route-path";
|
||||
|
||||
describe("validateRoutePath", () => {
|
||||
test("normalizes prefixes", () => {
|
||||
expect(validateRoutePath({ value: "" })).toEqual({
|
||||
success: true,
|
||||
value: "/",
|
||||
});
|
||||
expect(validateRoutePath({ value: "app/" })).toEqual({
|
||||
success: true,
|
||||
value: "/app",
|
||||
});
|
||||
expect(validateRoutePath({ value: "//app//media/" })).toEqual({
|
||||
success: true,
|
||||
value: "/app/media",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects traversal, whitespace and Caddyfile characters", () => {
|
||||
for (const value of ["/../etc", "/app/{x}", "/app x", "/app\nhandle"]) {
|
||||
expect(validateRoutePath({ value }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
type Params = {
|
||||
value?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a route path prefix to `/`, `/app` or `/app/nested`.
|
||||
* Rejects traversal, whitespace and Caddyfile control characters.
|
||||
*/
|
||||
export default function validateRoutePath({ value }: Params) {
|
||||
let path_prefix = (value || "/").trim();
|
||||
|
||||
if (!path_prefix.startsWith("/")) {
|
||||
path_prefix = `/${path_prefix}`;
|
||||
}
|
||||
|
||||
path_prefix = path_prefix.replace(/\/{2,}/g, "/").replace(/\/$/, "") || "/";
|
||||
|
||||
if (path_prefix.length > 512) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Path prefix is too long`,
|
||||
};
|
||||
}
|
||||
|
||||
if (/[\s?#{}\\'"`]/.test(path_prefix)) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Path prefix contains invalid characters`,
|
||||
};
|
||||
}
|
||||
|
||||
if (path_prefix.split("/").some((part) => part == ".." || part == ".")) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Path prefix cannot contain "." or ".."`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
value: path_prefix,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import validateUpstreamUrl from "./validate-upstream-url";
|
||||
|
||||
describe("validateUpstreamUrl", () => {
|
||||
test("accepts bare http and https origins", () => {
|
||||
expect(validateUpstreamUrl({ value: "http://10.5.0.20:8080" })).toEqual({
|
||||
success: true,
|
||||
value: "http://10.5.0.20:8080",
|
||||
secure: false,
|
||||
});
|
||||
|
||||
expect(validateUpstreamUrl({ value: "https://app.internal" })).toEqual({
|
||||
success: true,
|
||||
value: "https://app.internal",
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects paths, credentials, queries and non-http schemes", () => {
|
||||
for (const value of [
|
||||
"http://10.5.0.20:8080/app",
|
||||
"http://user:[email protected]:8080",
|
||||
"http://10.5.0.20:8080?x=1",
|
||||
"ftp://10.5.0.20:8080",
|
||||
"http://10.5.0.20:8080\nhandle",
|
||||
"",
|
||||
]) {
|
||||
expect(validateUpstreamUrl({ value }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
type Params = {
|
||||
value?: string | null;
|
||||
};
|
||||
|
||||
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
/**
|
||||
* Upstreams must be a bare http(s) origin. Paths, credentials, queries and
|
||||
* fragments are rejected so joining a route path can never escape the origin.
|
||||
*/
|
||||
export default function validateUpstreamUrl({ value }: Params) {
|
||||
const raw = (value || "").trim();
|
||||
|
||||
if (!raw) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Upstream URL is required`,
|
||||
};
|
||||
}
|
||||
|
||||
if (raw.length > 2048 || CONTROL_CHARS.test(raw)) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Invalid upstream URL`,
|
||||
};
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Invalid upstream URL`,
|
||||
};
|
||||
}
|
||||
|
||||
if (url.protocol != "http:" && url.protocol != "https:") {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Upstream URL must use http or https`,
|
||||
};
|
||||
}
|
||||
|
||||
if (url.username || url.password) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Upstream URL cannot contain credentials`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname != "/" ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
!url.hostname
|
||||
) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Upstream URL must be a bare origin, e.g. http://10.0.0.5:8080`,
|
||||
};
|
||||
}
|
||||
|
||||
const port = url.port
|
||||
? Number(url.port)
|
||||
: url.protocol == "https:"
|
||||
? 443
|
||||
: 80;
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return {
|
||||
success: false as const,
|
||||
msg: `Invalid upstream port`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
value: `${url.protocol}//${url.host}`,
|
||||
secure: url.protocol == "https:",
|
||||
};
|
||||
}
|
||||
@@ -52,11 +52,18 @@ export default async function grabClients(
|
||||
sql += `LEFT JOIN hosts h ON c.host_id = h.id\n`;
|
||||
sql += `WHERE 1=1\n`;
|
||||
|
||||
const target_host_id = params?.host_id || query?.host_id;
|
||||
const target_host_id =
|
||||
params?.host_id !== undefined
|
||||
? params.host_id
|
||||
: query?.host_id;
|
||||
const target_user_id = params?.user_id || query?.user_id;
|
||||
const target_client_id = params?.client_id || query?.client_id || query?.id;
|
||||
|
||||
if (target_host_id) {
|
||||
if (
|
||||
target_host_id !== undefined &&
|
||||
target_host_id !== null &&
|
||||
target_host_id !== ""
|
||||
) {
|
||||
sql += ` AND c.host_id=?\n`;
|
||||
values.push(target_host_id);
|
||||
}
|
||||
|
||||
@@ -455,6 +455,30 @@ describe("buildHostIptablesScripts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("allows the TLS ingress port from the wireguard interface", () => {
|
||||
const res = buildHostIptablesScripts({
|
||||
...base,
|
||||
clients: [],
|
||||
ingress_ports: [8443],
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.post_up).toContain(
|
||||
"iptables -A WGUI0IN -i wgui0 -p tcp --dport 8443 -j ACCEPT",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an invalid ingress port", () => {
|
||||
const res = buildHostIptablesScripts({
|
||||
...base,
|
||||
clients: [],
|
||||
ingress_ports: [70000],
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.msg).toBe("Invalid ingress port");
|
||||
});
|
||||
|
||||
test("rejects oversized host id", () => {
|
||||
const res = buildHostIptablesScripts({
|
||||
...base,
|
||||
|
||||
@@ -19,6 +19,8 @@ type Params = {
|
||||
host_subnet?: string;
|
||||
sibling_subnets?: string[];
|
||||
clients?: HostIptablesClient[];
|
||||
/** Host-level TCP ports clients may reach (e.g. the Caddy ingress). */
|
||||
ingress_ports?: number[];
|
||||
};
|
||||
|
||||
const SAFE_IFACE = /^[A-Za-z][A-Za-z0-9._-]*$/;
|
||||
@@ -148,6 +150,7 @@ export default function buildHostIptablesScripts({
|
||||
host_subnet,
|
||||
sibling_subnets,
|
||||
clients,
|
||||
ingress_ports,
|
||||
}: Params) {
|
||||
if (!Number.isSafeInteger(host_id) || host_id < 0 || host_id > 999999999) {
|
||||
return {
|
||||
@@ -184,6 +187,21 @@ export default function buildHostIptablesScripts({
|
||||
const accept_lines: string[] = [];
|
||||
const host_clients = clients || [];
|
||||
|
||||
for (let i = 0; i < (ingress_ports || []).length; i++) {
|
||||
const port = ingress_ports?.[i];
|
||||
|
||||
if (!Number.isInteger(port) || Number(port) < 1 || Number(port) > 65535) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Invalid ingress port`,
|
||||
};
|
||||
}
|
||||
|
||||
accept_lines.push(
|
||||
`iptables -A ${input} -i ${interface_name} -p tcp --dport ${port} -j ACCEPT`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < host_clients.length; i++) {
|
||||
const client = host_clients[i];
|
||||
|
||||
|
||||
@@ -84,6 +84,11 @@ const TOOL_CHECKS: {
|
||||
command: "bun",
|
||||
version_command: "bun --version",
|
||||
},
|
||||
{
|
||||
name: "caddy",
|
||||
command: "/opt/caddy/caddy",
|
||||
version_command: "/opt/caddy/caddy version",
|
||||
},
|
||||
];
|
||||
|
||||
const tryShell = ({
|
||||
|
||||
@@ -21,7 +21,10 @@ export default async function readWireguardClientConfig({
|
||||
}
|
||||
|
||||
const { HOST_CLIENTS_DIR } = grabHostDirnames({
|
||||
host: host_id ? { id: Number(host_id) } : undefined,
|
||||
host:
|
||||
host_id !== undefined && host_id !== ""
|
||||
? { id: Number(host_id) }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const config_path = path.join(
|
||||
|
||||
@@ -17,6 +17,7 @@ import grabClientDirnames from "./grab-client-dir-names";
|
||||
import buildHostIptablesScripts from "./build-host-iptables-scripts";
|
||||
import allowSubnetFirewallRules from "../firewall/allow-subnet-firewall-rules";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { TlsData } from "@/src/data/tls-data";
|
||||
|
||||
const { WIREGUARD_PRIVATE_KEY_FILE_NAME, WIREGUARD_PUBLIC_KEY_FILE_NAME } =
|
||||
grabDirNames();
|
||||
@@ -215,6 +216,9 @@ export default async function setupWireguardHost({
|
||||
interface_name: INTERFACE_NAME,
|
||||
host_subnet: HOST_SUBNET,
|
||||
sibling_subnets,
|
||||
ingress_ports: TlsData.IngressEnabled
|
||||
? [TlsData.CaddyHttpsPort]
|
||||
: [],
|
||||
clients: clients.map((client) => ({
|
||||
id: client.id,
|
||||
wg_ip_address: client.wg_ip_address,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Network,
|
||||
Server,
|
||||
Settings,
|
||||
Users,
|
||||
@@ -34,6 +35,7 @@ export const AdminNav: AdminNavGroup[] = [
|
||||
items: [
|
||||
{ title: "Hosts", url: "/admin/hosts", Icon: Server },
|
||||
{ title: "Clients", url: "/admin/clients", Icon: Users },
|
||||
{ title: "Routes", url: "/admin/routes", Icon: Network },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Eye, Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import { Eye, Network, Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import ActionMenu from "@/src/components/general/action-menu";
|
||||
import useDeleteHost from "@/src/components/general/use-delete-host";
|
||||
import deriveHostDisplayName from "@/src/utils/derive-host-display-name";
|
||||
@@ -20,6 +20,7 @@ export default function HostRowActions({ host, onDeleted }: Props) {
|
||||
items={[
|
||||
{ key: "view", title: "View", href: base, icon: <Eye size={14} /> },
|
||||
{ key: "clients", title: "Clients", href: `${base}/clients`, icon: <Users size={14} /> },
|
||||
{ key: "routes", title: "Client routes", href: `${base}/routes`, icon: <Network size={14} /> },
|
||||
{
|
||||
key: "add-client",
|
||||
title: "Add client",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import { Network, Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import useDeleteHost from "@/src/components/general/use-delete-host";
|
||||
import type { ActionMenuItem } from "@/src/components/general/action-menu/types";
|
||||
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
||||
@@ -24,6 +24,7 @@ export default function useHostHeaderActions({ host, host_id }: Params) {
|
||||
|
||||
const actions: ActionMenuItem[] = [
|
||||
{ key: "clients", title: "Clients", href: `/admin/hosts/${host_id}/clients`, icon: <Users size={14} /> },
|
||||
{ key: "routes", title: "Client routes", href: `/admin/hosts/${host_id}/routes`, icon: <Network size={14} /> },
|
||||
{ key: "edit", title: "Edit", href: `/admin/hosts/${host_id}/edit`, icon: <Pencil size={14} /> },
|
||||
...(host
|
||||
? [
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AppContext } from "@/src/pages/__root";
|
||||
import deriveHostDisplayName from "@/src/utils/derive-host-display-name";
|
||||
import HostStatsSection from "./(sections)/host-stats-section";
|
||||
import HostInterfaceSection from "./(sections)/host-interface-section";
|
||||
import ClientRoutesPanel from "@/src/components/general/client-routes-panel";
|
||||
import HostHeaderMeta from "./(partials)/host-header-meta";
|
||||
import useHostHeaderActions from "./(hooks)/use-host-header-actions";
|
||||
|
||||
@@ -45,6 +46,11 @@ export default function HostDetailPage() {
|
||||
<PageBody>
|
||||
<HostStatsSection host={host} clients={clients} />
|
||||
<HostClientsPanel host_id={host_id} />
|
||||
<ClientRoutesPanel
|
||||
host_id={host_id}
|
||||
title="Routes"
|
||||
add_href={`/admin/hosts/${host_id}/routes/add`}
|
||||
/>
|
||||
<HostInterfaceSection host={host} clients={clients} />
|
||||
</PageBody>
|
||||
</>
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { ActionMenuItem } from "@/src/components/general/action-menu/types";
|
||||
import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
|
||||
import useConfirm from "@/src/hooks/use-confirm";
|
||||
import useToast from "@/src/hooks/use-toast";
|
||||
|
||||
type Params = {
|
||||
route?: ClientRouteView | null;
|
||||
host_id: number;
|
||||
};
|
||||
|
||||
export default function useRouteHeaderActions({ route, host_id }: Params) {
|
||||
const { confirm } = useConfirm();
|
||||
const { toast } = useToast();
|
||||
const route_id = Number(route?.id || 0);
|
||||
|
||||
const primary_action: ActionMenuItem = {
|
||||
key: "edit",
|
||||
title: "Edit route",
|
||||
href: `/admin/hosts/${host_id}/routes/edit/${route_id}`,
|
||||
icon: <Pencil size={14} />,
|
||||
};
|
||||
|
||||
const actions: ActionMenuItem[] = route
|
||||
? [
|
||||
{
|
||||
key: "delete",
|
||||
title: "Delete route",
|
||||
tone: "danger",
|
||||
icon: <Trash2 size={14} />,
|
||||
onClick: async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete this route?",
|
||||
message: `${route.hostname} will stop being served by Caddy.`,
|
||||
confirm_label: "Delete route",
|
||||
tone: "danger",
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const res = await adminCrudHandler({
|
||||
action: "delete",
|
||||
table: "client_routes",
|
||||
id: route_id,
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
toast({ msg: "Route deleted", color: "success" });
|
||||
window.location.href = `/admin/hosts/${host_id}/routes`;
|
||||
} else {
|
||||
toast({
|
||||
msg: res.msg || "Could not delete route",
|
||||
color: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return { primary_action, actions };
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
import { useState } from "react";
|
||||
import { RefreshCw, ShieldCheck } from "lucide-react";
|
||||
import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import type { ClientCertificateView } from "@/src/functions/backend/acme/decorate-certificate";
|
||||
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
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 MutedText from "@/src/components/general/muted-text";
|
||||
import MonoText from "@/src/components/general/mono-text";
|
||||
import useConfirm from "@/src/hooks/use-confirm";
|
||||
import useToast from "@/src/hooks/use-toast";
|
||||
import type { ApiReqParams } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
type Props = {
|
||||
route: ClientRouteView;
|
||||
certificate?: ClientCertificateView | null;
|
||||
pending_challenge?: ManualChallenge | null;
|
||||
};
|
||||
|
||||
type ManualChallenge = {
|
||||
txt_name: string;
|
||||
txt_value: string;
|
||||
hostname?: string;
|
||||
staging?: boolean;
|
||||
};
|
||||
|
||||
function expiryLabel({ expires_at }: { expires_at?: number | null }) {
|
||||
if (!expires_at) return "No certificate";
|
||||
const days = Math.ceil((expires_at - Date.now()) / (24 * 60 * 60 * 1000));
|
||||
if (days < 0) return `Expired ${Math.abs(days)} day(s) ago`;
|
||||
return `Expires in ${days} day(s)`;
|
||||
}
|
||||
|
||||
function certificateTone(status?: string) {
|
||||
if (status == "active") return "success" as const;
|
||||
if (status == "expiring" || status == "issuing") return "warning" as const;
|
||||
if (status == "error") return "error" as const;
|
||||
return "neutral" as const;
|
||||
}
|
||||
|
||||
export default function RouteCertificateSection({
|
||||
route,
|
||||
certificate,
|
||||
pending_challenge,
|
||||
}: Props) {
|
||||
const { confirm } = useConfirm();
|
||||
const { toast } = useToast();
|
||||
const [current, setCurrent] = useState<ClientCertificateView | null>(
|
||||
certificate || null,
|
||||
);
|
||||
const [challenge, setChallenge] = useState<ManualChallenge | null>(
|
||||
pending_challenge || null,
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const route_id = Number(route.id || 0);
|
||||
const configured = Boolean(route.acme_email);
|
||||
const is_active =
|
||||
current?.status == "active" || current?.status == "expiring";
|
||||
|
||||
async function start({ force }: { force?: boolean }) {
|
||||
const confirmed = await confirm({
|
||||
title: force
|
||||
? "Start certificate renewal?"
|
||||
: "Start certificate issuance?",
|
||||
message: `${route.hostname} needs a _acme-challenge TXT record at your DNS registrar. The dashboard will show the exact value.`,
|
||||
confirm_label: force ? "Start renewal" : "Start issuance",
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const res = await fetchApi<ApiReqParams, APIResponseObject>(
|
||||
`/api/admin/tls/manual/start`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { route_id } as ApiReqParams,
|
||||
},
|
||||
);
|
||||
|
||||
if (res.success && res.singleRes) {
|
||||
setChallenge(res.singleRes as ManualChallenge);
|
||||
}
|
||||
|
||||
toast({
|
||||
msg:
|
||||
res.msg ||
|
||||
(res.success
|
||||
? "TXT record ready"
|
||||
: "Could not start certificate issuance"),
|
||||
color: res.success ? "success" : "error",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const res = await fetchApi<ApiReqParams, APIResponseObject>(
|
||||
`/api/admin/tls/manual/complete`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { route_id } as ApiReqParams,
|
||||
},
|
||||
);
|
||||
|
||||
if (res.singleRes) {
|
||||
setCurrent(res.singleRes as ClientCertificateView);
|
||||
}
|
||||
|
||||
setChallenge(null);
|
||||
|
||||
toast({
|
||||
msg:
|
||||
res.msg ||
|
||||
(res.success
|
||||
? "Certificate issued"
|
||||
: "Certificate validation failed"),
|
||||
color: res.success ? "success" : "error",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
await fetchApi<ApiReqParams, APIResponseObject>(
|
||||
`/api/admin/tls/manual/cancel`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { route_id } as ApiReqParams,
|
||||
},
|
||||
);
|
||||
|
||||
setChallenge(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Certificate"
|
||||
description="Manual DNS-01. Add the TXT record at your registrar, then validate."
|
||||
>
|
||||
<Stack className="gap-4 items-stretch">
|
||||
{!configured ? (
|
||||
<InlineAlert
|
||||
tone="warning"
|
||||
msg="Set the ACME email on the route form before issuing a certificate."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{current?.last_error ? (
|
||||
<InlineAlert tone="error" msg={current.last_error} />
|
||||
) : null}
|
||||
|
||||
{challenge ? (
|
||||
<Stack className="gap-4 items-stretch">
|
||||
<InlineAlert
|
||||
tone="info"
|
||||
msg="Add this TXT record at Cloudflare (DNS only). Wait for propagation, then click Validate. You can remove the TXT record after the certificate is issued."
|
||||
/>
|
||||
<KeyValueList
|
||||
items={[
|
||||
{
|
||||
key: "txt_name",
|
||||
label: "TXT name",
|
||||
value: challenge.txt_name,
|
||||
mono: true,
|
||||
copy_value: challenge.txt_name,
|
||||
},
|
||||
{
|
||||
key: "txt_value",
|
||||
label: "TXT value",
|
||||
value: challenge.txt_value,
|
||||
mono: true,
|
||||
copy_value: challenge.txt_value,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Row className="gap-2 flex-wrap">
|
||||
<Button
|
||||
title="Validate the TXT record"
|
||||
type="button"
|
||||
size="small"
|
||||
loading={busy}
|
||||
onClick={complete}
|
||||
>
|
||||
Validate
|
||||
</Button>
|
||||
<Button
|
||||
title="Cancel pending certificate request"
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={busy}
|
||||
onClick={cancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Row>
|
||||
</Stack>
|
||||
) : (
|
||||
<Row className="justify-between gap-3 flex-wrap">
|
||||
<Stack className="gap-1">
|
||||
<Row className="gap-2 flex-wrap">
|
||||
<StatusDot
|
||||
tone={certificateTone(current?.status)}
|
||||
label={current?.status || "missing"}
|
||||
/>
|
||||
<MutedText>
|
||||
{expiryLabel({
|
||||
expires_at: current?.expires_at,
|
||||
})}
|
||||
</MutedText>
|
||||
</Row>
|
||||
<Row className="gap-2 flex-wrap">
|
||||
<MonoText>{route.hostname || "—"}</MonoText>
|
||||
<MutedText>
|
||||
{route.acme_staging == 1
|
||||
? "Let's Encrypt staging"
|
||||
: "Let's Encrypt production"}
|
||||
</MutedText>
|
||||
</Row>
|
||||
</Stack>
|
||||
|
||||
{is_active ? (
|
||||
<Button
|
||||
title={`Renew certificate for ${route.hostname}`}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={busy}
|
||||
disabled={!configured}
|
||||
onClick={() => start({ force: true })}
|
||||
beforeIcon={<RefreshCw size={14} />}
|
||||
>
|
||||
Renew
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
title={`Issue certificate for ${route.hostname}`}
|
||||
type="button"
|
||||
size="small"
|
||||
loading={busy}
|
||||
disabled={!configured}
|
||||
onClick={() => start({})}
|
||||
beforeIcon={<ShieldCheck size={14} />}
|
||||
>
|
||||
Issue
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Stack>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { Pencil } from "lucide-react";
|
||||
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
||||
import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import Panel from "@/src/components/general/panel";
|
||||
import KeyValueList from "@/src/components/general/key-value-list";
|
||||
|
||||
type Props = {
|
||||
route: ClientRouteView;
|
||||
host?: BUN_SQLITE_WGUI_HOSTS | null;
|
||||
};
|
||||
|
||||
export default function RouteOverviewSection({ route, host }: Props) {
|
||||
const host_id = Number(route.host_id || 0);
|
||||
const route_id = Number(route.id || 0);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Route"
|
||||
description="Hostname, path and upstream for this route."
|
||||
actions={
|
||||
<Button
|
||||
title="Edit route"
|
||||
href={`/admin/hosts/${host_id}/routes/edit/${route_id}`}
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
beforeIcon={<Pencil size={14} />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<KeyValueList
|
||||
items={[
|
||||
{
|
||||
key: "host",
|
||||
label: "Host",
|
||||
value: host?.name || `Host #${host_id}`,
|
||||
},
|
||||
{
|
||||
key: "hostname",
|
||||
label: "Hostname",
|
||||
value: route.hostname || "—",
|
||||
mono: true,
|
||||
copy_value: route.hostname || "",
|
||||
},
|
||||
{
|
||||
key: "path",
|
||||
label: "Path prefix",
|
||||
value: route.path_prefix || "/",
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: "strip",
|
||||
label: "Strip prefix",
|
||||
value: route.strip_prefix == 1 ? "Yes" : "No",
|
||||
},
|
||||
{
|
||||
key: "upstream",
|
||||
label: "Upstream",
|
||||
value: route.upstream_url || "—",
|
||||
mono: true,
|
||||
copy_value: route.upstream_url || "",
|
||||
},
|
||||
{
|
||||
key: "verify",
|
||||
label: "Verify upstream TLS",
|
||||
value: route.upstream_tls_verify == 0 ? "No" : "Yes",
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "State",
|
||||
value: route.enabled == 0 ? "Disabled" : "Enabled",
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
label: "ACME email",
|
||||
value: route.acme_email || "Not set",
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: "challenge",
|
||||
label: "Challenge",
|
||||
value: "Manual DNS-01",
|
||||
},
|
||||
{
|
||||
key: "staging",
|
||||
label: "Staging CA",
|
||||
value: route.acme_staging == 1 ? "Yes" : "No",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
} from "@/db/types/db";
|
||||
import decorateClientRoute from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import grabCertificateByHostname from "@/src/functions/backend/acme/grab-certificate-by-hostname";
|
||||
import { grabManualDnsChallenge } from "@/src/functions/backend/acme/manual-dns-challenges";
|
||||
import type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
type RoutePageQuery = PageQueryObject & {
|
||||
route_id?: string;
|
||||
};
|
||||
|
||||
const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
|
||||
const page_query = query as RoutePageQuery;
|
||||
const route_id = Number(page_query.route_id || 0);
|
||||
|
||||
const host_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
|
||||
table: "hosts",
|
||||
targetId: page_query.host_id,
|
||||
});
|
||||
|
||||
const route_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
targetId: route_id,
|
||||
});
|
||||
|
||||
const route = route_res.singleRes;
|
||||
const certificate = route
|
||||
? await grabCertificateByHostname({ hostname: route.hostname })
|
||||
: null;
|
||||
const pending = route
|
||||
? grabManualDnsChallenge(Number(route.id || 0))
|
||||
: null;
|
||||
|
||||
return {
|
||||
props: {
|
||||
host: host_res.singleRes || null,
|
||||
client_route: route
|
||||
? decorateClientRoute({ route, certificate })
|
||||
: null,
|
||||
client_certificate: certificate,
|
||||
client_challenge: pending
|
||||
? {
|
||||
txt_name: pending.txt_name,
|
||||
txt_value: pending.txt_value,
|
||||
hostname: pending.hostname,
|
||||
staging: pending.staging,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default server;
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
||||
import { useContext } from "react";
|
||||
import { Network } from "lucide-react";
|
||||
import PageHeader from "@/src/components/general/page-header";
|
||||
import PageBody from "@/src/components/general/page-body";
|
||||
import EmptyState from "@/src/components/general/empty-state";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import RouteOverviewSection from "./(sections)/route-overview-section";
|
||||
import RouteCertificateSection from "./(sections)/route-certificate-section";
|
||||
import useRouteHeaderActions from "./(hooks)/use-route-header-actions";
|
||||
|
||||
export default function RouteDetailPage() {
|
||||
const { pageProps, query } = useContext(AppContext);
|
||||
const route = pageProps?.client_route;
|
||||
const host = pageProps?.host;
|
||||
const certificate = pageProps?.client_certificate;
|
||||
const challenge = pageProps?.client_challenge;
|
||||
const host_id = Number(
|
||||
query?.host_id ?? host?.id ?? route?.host_id ?? 0,
|
||||
);
|
||||
const { primary_action, actions } = useRouteHeaderActions({
|
||||
route,
|
||||
host_id,
|
||||
});
|
||||
|
||||
if (!route) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Route"
|
||||
back={{
|
||||
href: `/admin/hosts/${host_id}/routes`,
|
||||
title: "Routes",
|
||||
}}
|
||||
/>
|
||||
<PageBody>
|
||||
<EmptyState
|
||||
icon={<Network size={18} />}
|
||||
title="Route not found"
|
||||
description="It may have been deleted."
|
||||
/>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={route.hostname || route.name || "Route"}
|
||||
description={route.name || route.notes || undefined}
|
||||
back={{
|
||||
href: `/admin/hosts/${host_id}/routes`,
|
||||
title: "Routes",
|
||||
}}
|
||||
primary_action={primary_action}
|
||||
actions={actions}
|
||||
/>
|
||||
<PageBody>
|
||||
<RouteOverviewSection route={route} host={host} />
|
||||
<RouteCertificateSection
|
||||
route={route}
|
||||
certificate={certificate}
|
||||
pending_challenge={challenge}
|
||||
/>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMeta = {
|
||||
title: `Route | ${SiteData["SiteName"]}`,
|
||||
description: `TLS route details`,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
||||
import type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
|
||||
const page_query = query as PageQueryObject;
|
||||
|
||||
const host_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
|
||||
table: "hosts",
|
||||
targetId: page_query.host_id,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
host: host_res.singleRes || null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default server;
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
||||
import { useContext } from "react";
|
||||
import PageHeader from "@/src/components/general/page-header";
|
||||
import PageBody from "@/src/components/general/page-body";
|
||||
import ClientRouteForm from "@/src/components/general/client-route-form";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
|
||||
export default function AddClientRoutePage() {
|
||||
const { pageProps, query } = useContext(AppContext);
|
||||
const host = pageProps?.host;
|
||||
const host_id = Number(query?.host_id ?? host?.id ?? 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Add route"
|
||||
description="Terminate TLS for a hostname and forward it to an internal service."
|
||||
back={{
|
||||
href: `/admin/hosts/${host_id}/routes`,
|
||||
title: "Routes",
|
||||
}}
|
||||
/>
|
||||
<PageBody>
|
||||
<ClientRouteForm
|
||||
host_id={host_id}
|
||||
cancel_href={`/admin/hosts/${host_id}/routes`}
|
||||
/>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMeta = {
|
||||
title: `Add route | ${SiteData["SiteName"]}`,
|
||||
description: `Add a TLS route for a WireGuard host`,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
} from "@/db/types/db";
|
||||
import decorateClientRoute from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import grabCertificateByHostname from "@/src/functions/backend/acme/grab-certificate-by-hostname";
|
||||
import type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
type RouteEditPageQuery = PageQueryObject & {
|
||||
route_id?: string;
|
||||
};
|
||||
|
||||
const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
|
||||
const page_query = query as RouteEditPageQuery;
|
||||
const route_id = Number(page_query.route_id || 0);
|
||||
|
||||
const host_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
|
||||
table: "hosts",
|
||||
targetId: page_query.host_id,
|
||||
});
|
||||
|
||||
const route_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
targetId: route_id,
|
||||
});
|
||||
|
||||
const route = route_res.singleRes;
|
||||
|
||||
return {
|
||||
props: {
|
||||
host: host_res.singleRes || null,
|
||||
client_route: route
|
||||
? decorateClientRoute({
|
||||
route,
|
||||
certificate: await grabCertificateByHostname({
|
||||
hostname: route.hostname,
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default server;
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
||||
import { useContext } from "react";
|
||||
import PageHeader from "@/src/components/general/page-header";
|
||||
import PageBody from "@/src/components/general/page-body";
|
||||
import EmptyState from "@/src/components/general/empty-state";
|
||||
import ClientRouteForm from "@/src/components/general/client-route-form";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
|
||||
export default function EditClientRoutePage() {
|
||||
const { pageProps, query } = useContext(AppContext);
|
||||
const host = pageProps?.host;
|
||||
const route = pageProps?.client_route;
|
||||
const host_id = Number(query?.host_id ?? host?.id ?? 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Edit route"
|
||||
description="Changes regenerate the Caddyfile and reload the ingress."
|
||||
back={{
|
||||
href: `/admin/hosts/${host_id}/routes`,
|
||||
title: "Routes",
|
||||
}}
|
||||
/>
|
||||
<PageBody>
|
||||
{route ? (
|
||||
<ClientRouteForm
|
||||
host_id={host_id}
|
||||
existing_route={route}
|
||||
cancel_href={`/admin/hosts/${host_id}/routes`}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Route not found"
|
||||
description="It may have been deleted."
|
||||
/>
|
||||
)}
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMeta = {
|
||||
title: `Edit route | ${SiteData["SiteName"]}`,
|
||||
description: `Edit a TLS route for a WireGuard host`,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
||||
import type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
|
||||
const page_query = query as PageQueryObject;
|
||||
|
||||
const host_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
|
||||
table: "hosts",
|
||||
targetId: page_query.host_id,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
host: host_res.singleRes || null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default server;
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
||||
import { useContext } from "react";
|
||||
import PageHeader from "@/src/components/general/page-header";
|
||||
import PageBody from "@/src/components/general/page-body";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import ClientRoutesPanel from "@/src/components/general/client-routes-panel";
|
||||
|
||||
export default function HostRoutesPage() {
|
||||
const { pageProps, query } = useContext(AppContext);
|
||||
const host = pageProps?.host;
|
||||
const host_id = Number(query?.host_id ?? host?.id ?? 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Routes"
|
||||
description="Internal services exposed over HTTPS to this host's WireGuard clients."
|
||||
back={{ href: `/admin/hosts/${host_id}`, title: host?.name || "Host" }}
|
||||
/>
|
||||
<PageBody>
|
||||
<ClientRoutesPanel
|
||||
host_id={host_id}
|
||||
title="Routes"
|
||||
add_href={`/admin/hosts/${host_id}/routes/add`}
|
||||
/>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMeta = {
|
||||
title: `Routes | ${SiteData["SiteName"]}`,
|
||||
description: `TLS routes for a WireGuard host`,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
||||
import PageHeader from "@/src/components/general/page-header";
|
||||
import PageBody from "@/src/components/general/page-body";
|
||||
import ClientRoutesPanel from "@/src/components/general/client-routes-panel";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
export default function RoutesPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Routes"
|
||||
description="HTTPS services exposed to WireGuard clients, with their host and certificate status."
|
||||
/>
|
||||
<PageBody>
|
||||
<ClientRoutesPanel
|
||||
show_host
|
||||
title="All routes"
|
||||
description="Open a route to edit its TLS settings, issue certificates or inspect its upstream."
|
||||
/>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMeta = {
|
||||
title: `Routes | ${SiteData["SiteName"]}`,
|
||||
description: `TLS routes exposed to WireGuard clients`,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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`,
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import type { BUN_SQLITE_WGUI_TLS_CERTIFICATES } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { TableType } from "@/src/types";
|
||||
import grabCertDirNames from "@/src/functions/backend/caddy/grab-cert-dir-names";
|
||||
|
||||
type Params = {
|
||||
hostname: string;
|
||||
user_id?: number | "";
|
||||
};
|
||||
|
||||
/**
|
||||
* A route may exist before its certificate. Create the metadata row so the
|
||||
* setup page can list it and offer an "Issue" action.
|
||||
*/
|
||||
export default async function ensureCertificateRecord({
|
||||
hostname,
|
||||
user_id,
|
||||
}: Params) {
|
||||
const existing = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
TableType
|
||||
>({
|
||||
table: "tls_certificates",
|
||||
query: {
|
||||
query: {
|
||||
hostname: {
|
||||
value: hostname,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing.singleRes?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { CERT_FILE, KEY_FILE } = grabCertDirNames({ hostname });
|
||||
|
||||
await BunSQLite.insert({
|
||||
table: "tls_certificates",
|
||||
data: [
|
||||
{
|
||||
user_id,
|
||||
hostname,
|
||||
provider: "letsencrypt",
|
||||
challenge: "dns-01",
|
||||
cert_path: CERT_FILE,
|
||||
key_path: KEY_FILE,
|
||||
status: "missing",
|
||||
auto_renew: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress";
|
||||
import type { AdminCrudAPIParams, TableType } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
export default async function (
|
||||
params: AdminCrudAPIParams,
|
||||
): Promise<APIResponseObject> {
|
||||
const { id, user_types, user } = params;
|
||||
|
||||
const is_super_admin = checkUserAccess({ user_types });
|
||||
|
||||
if (!is_super_admin.success) {
|
||||
const is_admin = checkUserAccess({
|
||||
user_types,
|
||||
includes: ["admin"],
|
||||
});
|
||||
|
||||
if (!is_admin.success) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
}
|
||||
|
||||
const existing_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
targetId: id,
|
||||
});
|
||||
|
||||
const existing = existing_res.singleRes;
|
||||
|
||||
if (typeof existing?.id !== "number") {
|
||||
return { success: false, msg: `Route not found` };
|
||||
}
|
||||
|
||||
if (!is_super_admin.success && existing.user_id != user.id) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
|
||||
const deleted = await BunSQLite.delete<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
targetId: existing.id,
|
||||
});
|
||||
|
||||
if (!deleted.success) {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
const sync = await syncCaddyIngress({ reason: "route deleted" });
|
||||
|
||||
return {
|
||||
...deleted,
|
||||
msg: sync.msg,
|
||||
debug: { warnings: sync.warnings },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
} from "@/db/types/db";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import decorateClientRoute from "@/src/functions/backend/acme/decorate-client-route";
|
||||
import { grabManualDnsChallenge } from "@/src/functions/backend/acme/manual-dns-challenges";
|
||||
import type { AdminCrudAPIParams, TableType } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import _ from "lodash";
|
||||
|
||||
export default async function (
|
||||
params: AdminCrudAPIParams,
|
||||
): Promise<APIResponseObject> {
|
||||
const { table, user_types, body, id, query } = params;
|
||||
|
||||
const allowed = checkUserAccess({
|
||||
user_types,
|
||||
includes: ["admin"],
|
||||
});
|
||||
|
||||
if (!allowed.success) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
|
||||
const final_sql_query = _.merge(query?.sql_query, body?.sql_query) || {};
|
||||
|
||||
const GET = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table,
|
||||
query: final_sql_query,
|
||||
targetId: id,
|
||||
});
|
||||
|
||||
const certificates_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_TLS_CERTIFICATES,
|
||||
TableType
|
||||
>({
|
||||
table: "tls_certificates",
|
||||
});
|
||||
|
||||
const certificates_by_hostname = new Map(
|
||||
(certificates_res.payload || [])
|
||||
.filter((certificate) => certificate.hostname)
|
||||
.map((certificate) => [
|
||||
certificate.hostname!.trim().toLowerCase(),
|
||||
certificate,
|
||||
]),
|
||||
);
|
||||
|
||||
const decorate = (route: BUN_SQLITE_WGUI_CLIENT_ROUTES) => {
|
||||
const view = decorateClientRoute({
|
||||
route,
|
||||
certificate: certificates_by_hostname.get(route.hostname || ""),
|
||||
});
|
||||
|
||||
if (
|
||||
typeof route.id === "number" &&
|
||||
grabManualDnsChallenge(route.id)
|
||||
) {
|
||||
view.certificate_status = "issuing";
|
||||
}
|
||||
|
||||
return view;
|
||||
};
|
||||
|
||||
return {
|
||||
...GET,
|
||||
payload: GET.payload?.map((route) =>
|
||||
decorate(route as BUN_SQLITE_WGUI_CLIENT_ROUTES),
|
||||
),
|
||||
singleRes: GET.singleRes
|
||||
? decorate(GET.singleRes as BUN_SQLITE_WGUI_CLIENT_ROUTES)
|
||||
: GET.singleRes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AdminCrudAPIParams } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import get from "./get";
|
||||
import post from "./post";
|
||||
import put from "./put";
|
||||
import del from "./del";
|
||||
|
||||
export default async function (
|
||||
params: AdminCrudAPIParams,
|
||||
): Promise<APIResponseObject> {
|
||||
switch (params.req.method) {
|
||||
case "GET":
|
||||
return await get(params);
|
||||
case "POST":
|
||||
return await post(params);
|
||||
case "PUT":
|
||||
return await put(params);
|
||||
case "DELETE":
|
||||
return await del(params);
|
||||
|
||||
default:
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
} from "@/db/types/db";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import sanitizeClientRoute from "@/src/functions/backend/caddy/sanitize-client-route";
|
||||
import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress";
|
||||
import type { AdminCrudAPIParams, TableType } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import ensureCertificateRecord from "./(functions)/ensure-certificate-record";
|
||||
import sealRouteCredentials from "@/src/functions/backend/acme/seal-route-credentials";
|
||||
|
||||
export default async function (
|
||||
params: AdminCrudAPIParams,
|
||||
): Promise<APIResponseObject> {
|
||||
const { body, user_types, user } = params;
|
||||
|
||||
const is_super_admin = checkUserAccess({ user_types });
|
||||
|
||||
if (!is_super_admin.success) {
|
||||
const is_admin = checkUserAccess({
|
||||
user_types,
|
||||
includes: ["admin"],
|
||||
});
|
||||
|
||||
if (!is_admin.success) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
}
|
||||
|
||||
const prepared: BUN_SQLITE_WGUI_CLIENT_ROUTES[] = [];
|
||||
|
||||
for (let i = 0; i < (body?.insert_data || []).length; i++) {
|
||||
const sanitized = sanitizeClientRoute({
|
||||
route: body?.insert_data?.[i],
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
if (!sanitized.success) {
|
||||
return {
|
||||
success: false,
|
||||
msg: sanitized.msg,
|
||||
};
|
||||
}
|
||||
|
||||
const sealed = await sealRouteCredentials({
|
||||
text: sanitized.route.acme_credentials,
|
||||
});
|
||||
|
||||
if (!sealed.success) {
|
||||
return { success: false, msg: sealed.msg };
|
||||
}
|
||||
|
||||
const { id: _ignored_id, ...route_data } = sanitized.route;
|
||||
|
||||
prepared.push({
|
||||
...route_data,
|
||||
acme_credentials: sealed.value,
|
||||
user_id: is_super_admin.success
|
||||
? route_data.user_id
|
||||
: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (!prepared[0]) {
|
||||
return { success: false, msg: `No routes to insert` };
|
||||
}
|
||||
|
||||
const host_ids = [...new Set(prepared.map((route) => Number(route.host_id)))];
|
||||
|
||||
for (let i = 0; i < host_ids.length; i++) {
|
||||
const host_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
TableType
|
||||
>({
|
||||
table: "hosts",
|
||||
targetId: host_ids[i],
|
||||
});
|
||||
|
||||
const host = host_res.singleRes;
|
||||
|
||||
if (typeof host?.id !== "number") {
|
||||
return { success: false, msg: `Host not found` };
|
||||
}
|
||||
|
||||
if (!is_super_admin.success && host.user_id != user.id) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
}
|
||||
|
||||
const insert = await BunSQLite.insert<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
data: prepared,
|
||||
update_on_duplicate: false,
|
||||
});
|
||||
|
||||
if (!insert.success) {
|
||||
return insert;
|
||||
}
|
||||
|
||||
for (let i = 0; i < prepared.length; i++) {
|
||||
const route = prepared[i];
|
||||
|
||||
if (route?.hostname) {
|
||||
await ensureCertificateRecord({
|
||||
hostname: route.hostname,
|
||||
user_id: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sync = await syncCaddyIngress({ reason: "route created" });
|
||||
|
||||
return {
|
||||
...insert,
|
||||
msg: sync.msg,
|
||||
debug: { warnings: sync.warnings },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
} from "@/db/types/db";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import sanitizeClientRoute from "@/src/functions/backend/caddy/sanitize-client-route";
|
||||
import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress";
|
||||
import type { AdminCrudAPIParams, TableType } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import ensureCertificateRecord from "./(functions)/ensure-certificate-record";
|
||||
import sealRouteCredentials from "@/src/functions/backend/acme/seal-route-credentials";
|
||||
|
||||
export default async function (
|
||||
params: AdminCrudAPIParams,
|
||||
): Promise<APIResponseObject> {
|
||||
const { body, id, user_types, user } = params;
|
||||
|
||||
if (!body?.update_data) {
|
||||
return { success: false, msg: `No update data` };
|
||||
}
|
||||
|
||||
const is_super_admin = checkUserAccess({ user_types });
|
||||
|
||||
if (!is_super_admin.success) {
|
||||
const is_admin = checkUserAccess({
|
||||
user_types,
|
||||
includes: ["admin"],
|
||||
});
|
||||
|
||||
if (!is_admin.success) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
}
|
||||
|
||||
const existing_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
targetId: id,
|
||||
});
|
||||
|
||||
const existing = existing_res.singleRes;
|
||||
|
||||
if (typeof existing?.id !== "number") {
|
||||
return { success: false, msg: `Route not found` };
|
||||
}
|
||||
|
||||
if (!is_super_admin.success && existing.user_id != user.id) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
|
||||
const update_data = {
|
||||
...(body.update_data as BUN_SQLITE_WGUI_CLIENT_ROUTES),
|
||||
};
|
||||
const new_credentials_text =
|
||||
typeof update_data.acme_credentials == "string"
|
||||
? update_data.acme_credentials
|
||||
: "";
|
||||
|
||||
// Blank means "keep the existing encrypted credentials".
|
||||
delete update_data.acme_credentials;
|
||||
|
||||
const sanitized = sanitizeClientRoute({
|
||||
route: { ...existing, ...update_data },
|
||||
user_id: existing.user_id,
|
||||
host_id: existing.host_id,
|
||||
});
|
||||
|
||||
if (!sanitized.success) {
|
||||
return { success: false, msg: sanitized.msg };
|
||||
}
|
||||
|
||||
if (new_credentials_text?.trim()) {
|
||||
const sealed = await sealRouteCredentials({
|
||||
text: new_credentials_text,
|
||||
existing: existing.acme_credentials,
|
||||
});
|
||||
|
||||
if (!sealed.success) {
|
||||
return { success: false, msg: sealed.msg };
|
||||
}
|
||||
|
||||
sanitized.route.acme_credentials = sealed.value;
|
||||
}
|
||||
|
||||
const host_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
|
||||
table: "hosts",
|
||||
targetId: sanitized.route.host_id,
|
||||
});
|
||||
|
||||
if (typeof host_res.singleRes?.id !== "number") {
|
||||
return { success: false, msg: `Host not found` };
|
||||
}
|
||||
|
||||
if (
|
||||
!is_super_admin.success &&
|
||||
host_res.singleRes.user_id != user.id
|
||||
) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
|
||||
sanitized.route.user_id = is_super_admin.success
|
||||
? existing.user_id
|
||||
: user.id;
|
||||
|
||||
const update = await BunSQLite.update<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
targetId: existing.id,
|
||||
data: sanitized.route,
|
||||
});
|
||||
|
||||
if (!update.success) {
|
||||
return update;
|
||||
}
|
||||
|
||||
await ensureCertificateRecord({
|
||||
hostname: sanitized.route.hostname || "",
|
||||
user_id: sanitized.route.user_id,
|
||||
});
|
||||
|
||||
const sync = await syncCaddyIngress({ reason: "route updated" });
|
||||
|
||||
return {
|
||||
...update,
|
||||
msg: sync.msg,
|
||||
debug: { warnings: sync.warnings },
|
||||
};
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export default async function runClientSetup({
|
||||
|
||||
let host: BUN_SQLITE_WGUI_HOSTS | undefined;
|
||||
|
||||
if (client?.host_id) {
|
||||
if (client?.host_id !== undefined && client.host_id !== "") {
|
||||
const host_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
TableType
|
||||
|
||||
@@ -100,7 +100,7 @@ export default async function (
|
||||
try {
|
||||
let host: BUN_SQLITE_WGUI_HOSTS | undefined;
|
||||
|
||||
if (host_id) {
|
||||
if (Number.isSafeInteger(host_id) && host_id >= 0) {
|
||||
const host_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
TableType
|
||||
|
||||
@@ -15,6 +15,7 @@ import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import clients from "./(tables)/clients";
|
||||
import clientRules from "./(tables)/client_rules";
|
||||
import clientRoutes from "./(tables)/client_routes";
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
|
||||
params,
|
||||
@@ -64,6 +65,13 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
|
||||
return await clients(crud_params);
|
||||
case "client_rules":
|
||||
return await clientRules(crud_params);
|
||||
case "client_routes":
|
||||
return await clientRoutes(crud_params);
|
||||
case "tls_certificates":
|
||||
return {
|
||||
success: false,
|
||||
msg: `TLS certificates are managed by the TLS ingress`,
|
||||
};
|
||||
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -33,7 +33,11 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
|
||||
try {
|
||||
let host: BUN_SQLITE_WGUI_HOSTS | undefined;
|
||||
|
||||
if (body?.host_id) {
|
||||
if (
|
||||
body?.host_id !== undefined &&
|
||||
body.host_id !== null &&
|
||||
body.host_id !== ""
|
||||
) {
|
||||
const host_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
TableType
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import adminApiGuard from "@/src/functions/backend/auth/admin-api-guard";
|
||||
import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress";
|
||||
import runSetupScript from "@/src/functions/backend/setup/run-setup-script";
|
||||
import { TlsData } from "@/src/data/tls-data";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
const INSTALL_CADDY_SCRIPT = `install-caddy.sh`;
|
||||
const INSTALL_CADDY_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
|
||||
const {
|
||||
WGUI_LIB_DIR,
|
||||
WGUI_CADDY_BIN_DIR,
|
||||
WGUI_ACME_HOME,
|
||||
WGUI_ACME_ENV_FILE,
|
||||
} = grabDirNames();
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
}) => {
|
||||
if (req.method !== "POST") {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const unauthorized = await adminApiGuard({ req });
|
||||
|
||||
if (unauthorized) {
|
||||
return unauthorized;
|
||||
}
|
||||
|
||||
const install = runSetupScript({
|
||||
script: INSTALL_CADDY_SCRIPT,
|
||||
timeout_ms: INSTALL_CADDY_TIMEOUT_MS,
|
||||
env: {
|
||||
WGUI_LIB_DIR,
|
||||
CADDY_VERSION: process.env.CADDY_VERSION,
|
||||
CADDY_BIN_DIR: WGUI_CADDY_BIN_DIR,
|
||||
CADDY_HTTPS_PORT: String(TlsData.CaddyHttpsPort),
|
||||
CADDY_HTTP_PORT: String(TlsData.CaddyHttpPort),
|
||||
ACME_HOME: WGUI_ACME_HOME,
|
||||
ACME_ENV_FILE: WGUI_ACME_ENV_FILE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!install.success) {
|
||||
return {
|
||||
success: false,
|
||||
msg: install.output,
|
||||
};
|
||||
}
|
||||
|
||||
const sync = await syncCaddyIngress({ reason: "caddy installed" });
|
||||
|
||||
return {
|
||||
success: sync.success,
|
||||
msg: [install.output, sync.msg].filter(Boolean).join("\n\n"),
|
||||
debug: { warnings: sync.warnings },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
BUN_SQLITE_WGUI_USER_TYPES,
|
||||
} from "@/db/types/db";
|
||||
import type { User } from "@/src/types";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { TableType } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
req: Request;
|
||||
route_id?: number | "";
|
||||
};
|
||||
|
||||
type Result =
|
||||
| {
|
||||
success: true;
|
||||
user: User;
|
||||
user_types: BUN_SQLITE_WGUI_USER_TYPES[];
|
||||
route: BUN_SQLITE_WGUI_CLIENT_ROUTES;
|
||||
}
|
||||
| { success: false; msg: string; logoutUser?: boolean };
|
||||
|
||||
export default async function guardRouteAccess({
|
||||
req,
|
||||
route_id,
|
||||
}: Params): Promise<Result> {
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Unauthorized`,
|
||||
logoutUser: true,
|
||||
};
|
||||
}
|
||||
|
||||
const is_admin = checkUserAccess({
|
||||
user_types,
|
||||
includes: ["admin"],
|
||||
});
|
||||
|
||||
if (!is_admin.success) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
|
||||
const final_route_id = Number(route_id);
|
||||
|
||||
if (!Number.isSafeInteger(final_route_id) || final_route_id < 1) {
|
||||
return { success: false, msg: `Invalid route` };
|
||||
}
|
||||
|
||||
const route_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
targetId: final_route_id,
|
||||
});
|
||||
|
||||
const route = route_res.singleRes;
|
||||
|
||||
if (!route) {
|
||||
return { success: false, msg: `Route not found` };
|
||||
}
|
||||
|
||||
const is_super_admin = checkUserAccess({ user_types }).success;
|
||||
|
||||
if (!is_super_admin && route.user_id != user.id) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
|
||||
return { success: true, user, user_types: user_types || [], route };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import issueCertificate from "@/src/functions/backend/acme/issue-certificate";
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { ApiReqParams, TableType } from "@/src/types";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
if (req.method !== "POST") {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Unauthorized`,
|
||||
logoutUser: true,
|
||||
};
|
||||
}
|
||||
|
||||
const is_admin = checkUserAccess({
|
||||
user_types,
|
||||
includes: ["admin"],
|
||||
});
|
||||
|
||||
if (!is_admin.success) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
|
||||
const payload = body as
|
||||
| (ApiReqParams & {
|
||||
route_id?: number | "";
|
||||
hostname?: string;
|
||||
staging?: boolean;
|
||||
force?: boolean;
|
||||
})
|
||||
| undefined;
|
||||
|
||||
let route: BUN_SQLITE_WGUI_CLIENT_ROUTES | undefined;
|
||||
|
||||
if (payload?.route_id !== undefined && payload.route_id !== "") {
|
||||
const route_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
targetId: payload.route_id,
|
||||
});
|
||||
|
||||
route = route_res.singleRes || undefined;
|
||||
} else if (payload?.hostname) {
|
||||
const route_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_CLIENT_ROUTES,
|
||||
TableType
|
||||
>({
|
||||
table: "client_routes",
|
||||
query: {
|
||||
query: {
|
||||
hostname: {
|
||||
value: payload.hostname.trim().toLowerCase(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
route = route_res.singleRes || undefined;
|
||||
}
|
||||
|
||||
if (!route || typeof route.id !== "number") {
|
||||
return { success: false, msg: `Route not found` };
|
||||
}
|
||||
|
||||
const is_super_admin = checkUserAccess({ user_types }).success;
|
||||
|
||||
if (!is_super_admin && route.user_id != user.id) {
|
||||
return { success: false, msg: `Unauthorized` };
|
||||
}
|
||||
|
||||
return await issueCertificate({
|
||||
route,
|
||||
route_id: route.id,
|
||||
hostname: route.hostname,
|
||||
user_id: user.id,
|
||||
staging: payload?.staging,
|
||||
force: Boolean(payload?.force),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { removeManualDnsChallenge } from "@/src/functions/backend/acme/manual-dns-challenges";
|
||||
import type { ApiReqParams } from "@/src/types";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
import guardRouteAccess from "../(functions)/guard-route-access";
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
if (req.method !== "POST") {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const payload = body as (ApiReqParams & { route_id?: number | "" }) | undefined;
|
||||
const guard = await guardRouteAccess({
|
||||
req,
|
||||
route_id: payload?.route_id,
|
||||
});
|
||||
|
||||
if (!guard.success) {
|
||||
return {
|
||||
success: false,
|
||||
msg: guard.msg,
|
||||
logoutUser: guard.logoutUser,
|
||||
};
|
||||
}
|
||||
|
||||
removeManualDnsChallenge(Number(guard.route.id));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Pending certificate request cancelled`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import completeManualCertificate from "@/src/functions/backend/acme/complete-manual-certificate";
|
||||
import type { ApiReqParams } from "@/src/types";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
import guardRouteAccess from "../(functions)/guard-route-access";
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
if (req.method !== "POST") {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const payload = body as (ApiReqParams & { route_id?: number | "" }) | undefined;
|
||||
const guard = await guardRouteAccess({
|
||||
req,
|
||||
route_id: payload?.route_id,
|
||||
});
|
||||
|
||||
if (!guard.success) {
|
||||
return {
|
||||
success: false,
|
||||
msg: guard.msg,
|
||||
logoutUser: guard.logoutUser,
|
||||
};
|
||||
}
|
||||
|
||||
return await completeManualCertificate({
|
||||
route_id: guard.route.id,
|
||||
user_id: guard.user.id,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import startManualCertificate from "@/src/functions/backend/acme/start-manual-certificate";
|
||||
import type { ApiReqParams } from "@/src/types";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
import guardRouteAccess from "../(functions)/guard-route-access";
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
if (req.method !== "POST") {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const payload = body as (ApiReqParams & { route_id?: number | "" }) | undefined;
|
||||
const guard = await guardRouteAccess({
|
||||
req,
|
||||
route_id: payload?.route_id,
|
||||
});
|
||||
|
||||
if (!guard.success) {
|
||||
return {
|
||||
success: false,
|
||||
msg: guard.msg,
|
||||
logoutUser: guard.logoutUser,
|
||||
};
|
||||
}
|
||||
|
||||
return await startManualCertificate({
|
||||
route_id: guard.route.id,
|
||||
user_id: guard.user.id,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import adminApiGuard from "@/src/functions/backend/auth/admin-api-guard";
|
||||
import grabTlsStatus from "@/src/functions/backend/acme/grab-tls-status";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
}) => {
|
||||
if (req.method !== "GET") {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const unauthorized = await adminApiGuard({ req });
|
||||
|
||||
if (unauthorized) {
|
||||
return unauthorized;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await grabTlsStatus();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payload: [status],
|
||||
singleRes: status,
|
||||
} as APIResponseObject;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import adminApiGuard from "@/src/functions/backend/auth/admin-api-guard";
|
||||
import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
}) => {
|
||||
if (req.method !== "POST") {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const unauthorized = await adminApiGuard({ req });
|
||||
|
||||
if (unauthorized) {
|
||||
return unauthorized;
|
||||
}
|
||||
|
||||
const sync = await syncCaddyIngress({ reason: "manual sync" });
|
||||
|
||||
return {
|
||||
success: sync.success,
|
||||
msg: sync.msg,
|
||||
debug: { warnings: sync.warnings },
|
||||
};
|
||||
};
|
||||
@@ -82,8 +82,6 @@ export const handler: BunextAPIRouteHandler<
|
||||
],
|
||||
});
|
||||
|
||||
console.log("add_super_admin_user_types", add_super_admin_user_types);
|
||||
|
||||
return {
|
||||
...POST,
|
||||
singleRes: new_users_res.singleRes,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Form from "@/src/components/twui/form/Form";
|
||||
import InlineAlert from "@/src/components/general/inline-alert";
|
||||
import useFormInit from "@/src/hooks/use-form-init";
|
||||
import useConfirm from "@/src/hooks/use-confirm";
|
||||
import { type LoginFormObject } from "@/src/types";
|
||||
import LoginFormEmailUsername from "./login-form-email-username";
|
||||
import LoginFormPassword from "./login-form-password";
|
||||
@@ -10,12 +11,21 @@ import LoginFormAction from "./login-form-action";
|
||||
export default function LoginForm() {
|
||||
const init = useFormInit<LoginFormObject>();
|
||||
const { status } = init;
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="w-full items-stretch gap-5"
|
||||
onSubmit={() => {
|
||||
submitLoginForm(init);
|
||||
onSubmit={async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Sign in?",
|
||||
message: "Use these credentials to sign in to the dashboard.",
|
||||
confirm_label: "Sign in",
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
submitLoginForm(init);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status?.error ? <InlineAlert tone="error" msg={status.msg} /> : null}
|
||||
|
||||
Executable
+367
@@ -0,0 +1,367 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Install or repair the Caddy TLS ingress used by client routes.
|
||||
# Safe to re-run: it preserves the existing Caddyfile and certificate data.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
log() { echo "==> $*"; }
|
||||
fail() { echo "error: $*" >&2; exit 1; }
|
||||
|
||||
WGUI_LIB_DIR="${WGUI_LIB_DIR:-/var/lib/wgui}"
|
||||
CADDY_VERSION="${CADDY_VERSION:-2.11.4}"
|
||||
CADDY_BIN_DIR="${CADDY_BIN_DIR:-/opt/caddy}"
|
||||
CADDY_BIN="$CADDY_BIN_DIR/caddy"
|
||||
CADDY_USER="${CADDY_USER:-wgui-caddy}"
|
||||
CADDY_HTTPS_PORT="${CADDY_HTTPS_PORT:-8443}"
|
||||
CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-8081}"
|
||||
ACME_HOME="${ACME_HOME:-/opt/acme.sh}"
|
||||
ACME_ENV_FILE="${ACME_ENV_FILE:-/etc/wgui/acme.env}"
|
||||
SERVICE_NAME="wgui-caddy"
|
||||
|
||||
require_root() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
fail "this script must be run as root"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_distro() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
echo "$ID"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_init_system() {
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
echo "systemd"
|
||||
elif [ -x /sbin/openrc-run ] || [ -f /etc/alpine-release ]; then
|
||||
echo "openrc"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
caddy_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64) echo "amd64" ;;
|
||||
aarch64 | arm64 | armv8l | armv8) echo "arm64" ;;
|
||||
armv7l | armv7) echo "armv7" ;;
|
||||
armv6l | armv6) echo "armv6" ;;
|
||||
*) fail "unsupported architecture for Caddy: $(uname -m)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_deps() {
|
||||
local missing=false
|
||||
local tool
|
||||
|
||||
for tool in curl tar git openssl; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$missing" = false ]; then
|
||||
log "Caddy dependencies already installed"
|
||||
return
|
||||
fi
|
||||
|
||||
local distro
|
||||
distro="$(detect_distro)"
|
||||
|
||||
log "installing Caddy dependencies for $distro ..."
|
||||
|
||||
case "$distro" in
|
||||
debian | ubuntu | linuxmint | raspbian)
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y
|
||||
apt-get install -y curl ca-certificates openssl git tar
|
||||
;;
|
||||
fedora | rhel | centos | rocky | almalinux | ol)
|
||||
dnf install -y curl ca-certificates openssl git tar
|
||||
;;
|
||||
arch | manjaro | endeavouros)
|
||||
pacman -Syu --noconfirm --needed curl ca-certificates openssl git tar
|
||||
;;
|
||||
alpine)
|
||||
apk add --no-cache bash curl ca-certificates-bundle openssl git tar
|
||||
;;
|
||||
*)
|
||||
fail "unsupported distro: $distro"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_caddy() {
|
||||
if [ -x "$CADDY_BIN" ]; then
|
||||
if [ ! -e /usr/local/bin/caddy ]; then
|
||||
ln -sf "$CADDY_BIN" /usr/local/bin/caddy
|
||||
fi
|
||||
log "caddy: $($CADDY_BIN version)"
|
||||
return
|
||||
fi
|
||||
|
||||
local arch
|
||||
arch="$(caddy_arch)"
|
||||
local archive="caddy_${CADDY_VERSION}_linux_${arch}.tar.gz"
|
||||
local base_url="https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}"
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
|
||||
log "installing Caddy ${CADDY_VERSION} (${arch}) to ${CADDY_BIN_DIR} ..."
|
||||
mkdir -p "$CADDY_BIN_DIR"
|
||||
|
||||
curl -fsSL -o "$tmp_dir/$archive" "$base_url/$archive"
|
||||
curl -fsSL -o "$tmp_dir/checksums.txt" "$base_url/caddy_${CADDY_VERSION}_checksums.txt"
|
||||
|
||||
if command -v sha512sum >/dev/null 2>&1; then
|
||||
(cd "$tmp_dir" && grep " $archive\$" checksums.txt | sha512sum -c -)
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
local expected actual
|
||||
expected="$(grep " $archive\$" "$tmp_dir/checksums.txt" | awk '{print $1}')"
|
||||
actual="$(openssl dgst -sha512 "$tmp_dir/$archive" | awk '{print $NF}')"
|
||||
|
||||
if [ -z "$expected" ] || [ "$expected" != "$actual" ]; then
|
||||
fail "Caddy checksum verification failed for $archive"
|
||||
fi
|
||||
else
|
||||
fail "no sha512 verification tool available (install coreutils or openssl)"
|
||||
fi
|
||||
|
||||
tar -xzf "$tmp_dir/$archive" -C "$CADDY_BIN_DIR" caddy
|
||||
chmod 755 "$CADDY_BIN"
|
||||
if [ ! -e /usr/local/bin/caddy ]; then
|
||||
ln -sf "$CADDY_BIN" /usr/local/bin/caddy
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
|
||||
log "caddy: $($CADDY_BIN version)"
|
||||
}
|
||||
|
||||
install_acme_sh() {
|
||||
if [ -x "$ACME_HOME/acme.sh" ]; then
|
||||
log "acme.sh already present at $ACME_HOME"
|
||||
return
|
||||
fi
|
||||
|
||||
log "installing acme.sh to $ACME_HOME ..."
|
||||
rm -rf "$ACME_HOME"
|
||||
git clone --depth 1 https://github.com/acmesh-official/acme.sh.git "$ACME_HOME"
|
||||
chmod 700 "$ACME_HOME"
|
||||
chmod 700 "$ACME_HOME/acme.sh"
|
||||
}
|
||||
|
||||
setup_caddy() {
|
||||
if ! id -u "$CADDY_USER" >/dev/null 2>&1; then
|
||||
log "creating service user $CADDY_USER ..."
|
||||
if command -v useradd >/dev/null 2>&1; then
|
||||
useradd --system --user-group --home-dir "$WGUI_LIB_DIR/caddy" --shell /usr/sbin/nologin "$CADDY_USER"
|
||||
else
|
||||
adduser -S -H -h "$WGUI_LIB_DIR/caddy" -s /sbin/nologin "$CADDY_USER"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! id -g "$CADDY_USER" >/dev/null 2>&1 && command -v groupadd >/dev/null 2>&1; then
|
||||
groupadd --system "$CADDY_USER"
|
||||
fi
|
||||
|
||||
mkdir -p "$WGUI_LIB_DIR/caddy/data" "$WGUI_LIB_DIR/tls"
|
||||
chmod 755 "$WGUI_LIB_DIR"
|
||||
chown root:"$CADDY_USER" "$WGUI_LIB_DIR/tls"
|
||||
chmod 750 "$WGUI_LIB_DIR/tls"
|
||||
chown -R "$CADDY_USER:$CADDY_USER" "$WGUI_LIB_DIR/caddy"
|
||||
chmod 750 "$WGUI_LIB_DIR/caddy"
|
||||
chmod 750 "$WGUI_LIB_DIR/caddy/data"
|
||||
|
||||
mkdir -p /etc/wgui
|
||||
chmod 700 /etc/wgui
|
||||
|
||||
if [ ! -f "$ACME_ENV_FILE" ]; then
|
||||
log "writing $ACME_ENV_FILE template ..."
|
||||
cat > "$ACME_ENV_FILE" <<EOF
|
||||
# Let's Encrypt DNS-01 settings for wg-ui.
|
||||
ACME_EMAIL=
|
||||
ACME_DNS_PROVIDER=
|
||||
# Add the credentials your DNS provider's acme.sh hook expects, e.g.
|
||||
# CF_Token=...
|
||||
EOF
|
||||
chmod 600 "$ACME_ENV_FILE"
|
||||
chown root:root "$ACME_ENV_FILE"
|
||||
fi
|
||||
|
||||
if [ ! -f "$WGUI_LIB_DIR/caddy/Caddyfile" ]; then
|
||||
log "writing initial Caddyfile ..."
|
||||
cat > "$WGUI_LIB_DIR/caddy/Caddyfile" <<EOF
|
||||
{
|
||||
https_port $CADDY_HTTPS_PORT
|
||||
http_port $CADDY_HTTP_PORT
|
||||
auto_https disable_redirects
|
||||
admin unix//run/wgui-caddy/admin.sock
|
||||
storage file_system $WGUI_LIB_DIR/caddy/data
|
||||
}
|
||||
EOF
|
||||
chown "$CADDY_USER:$CADDY_USER" "$WGUI_LIB_DIR/caddy/Caddyfile"
|
||||
chmod 640 "$WGUI_LIB_DIR/caddy/Caddyfile"
|
||||
fi
|
||||
}
|
||||
|
||||
install_systemd_unit() {
|
||||
local unit="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
log "writing systemd unit $unit ..."
|
||||
cat > "$unit" <<EOF
|
||||
[Unit]
|
||||
Description=wg-ui TLS ingress (Caddy)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$CADDY_USER
|
||||
Group=$CADDY_USER
|
||||
Environment=HOME=$WGUI_LIB_DIR/caddy
|
||||
ExecStart=$CADDY_BIN run --config $WGUI_LIB_DIR/caddy/Caddyfile --adapter caddyfile
|
||||
ExecReload=$CADDY_BIN reload --config $WGUI_LIB_DIR/caddy/Caddyfile --adapter caddyfile --address unix//run/wgui-caddy/admin.sock
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
RuntimeDirectory=wgui-caddy
|
||||
RuntimeDirectoryMode=0750
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=$WGUI_LIB_DIR/caddy
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME.service"
|
||||
systemctl restart "$SERVICE_NAME.service"
|
||||
}
|
||||
|
||||
install_openrc_unit() {
|
||||
local init_script="/etc/init.d/$SERVICE_NAME"
|
||||
log "writing openrc init script $init_script ..."
|
||||
cat > "$init_script" <<EOF
|
||||
#!/sbin/openrc-run
|
||||
name="$SERVICE_NAME"
|
||||
description="wg-ui TLS ingress (Caddy)"
|
||||
command="$CADDY_BIN"
|
||||
command_args="run --config $WGUI_LIB_DIR/caddy/Caddyfile --adapter caddyfile"
|
||||
command_user="$CADDY_USER:$CADDY_USER"
|
||||
directory="$WGUI_LIB_DIR/caddy"
|
||||
pidfile="/run/\${RC_SVCNAME}.pid"
|
||||
|
||||
start_pre() {
|
||||
mkdir -p /run/wgui-caddy
|
||||
chown "$CADDY_USER:$CADDY_USER" /run/wgui-caddy
|
||||
}
|
||||
|
||||
reload() {
|
||||
ebegin "Reloading Caddy"
|
||||
"$CADDY_BIN" reload --config "$WGUI_LIB_DIR/caddy/Caddyfile" --adapter caddyfile --address unix//run/wgui-caddy/admin.sock
|
||||
eend \$?
|
||||
}
|
||||
|
||||
depend() {
|
||||
need net
|
||||
}
|
||||
EOF
|
||||
chmod +x "$init_script"
|
||||
rc-update add "$SERVICE_NAME" default
|
||||
rc-service "$SERVICE_NAME" restart
|
||||
}
|
||||
|
||||
install_systemd_timer() {
|
||||
local service="/etc/systemd/system/wgui-acme-renew.service"
|
||||
local timer="/etc/systemd/system/wgui-acme-renew.timer"
|
||||
|
||||
log "writing certificate renewal units ..."
|
||||
cat > "$service" <<EOF
|
||||
[Unit]
|
||||
Description=Renew wg-ui TLS certificates
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
EnvironmentFile=-$ACME_ENV_FILE
|
||||
Environment=LE_WORKING_DIR=$ACME_HOME
|
||||
ExecStart=$ACME_HOME/acme.sh --cron --home $ACME_HOME
|
||||
EOF
|
||||
|
||||
cat > "$timer" <<EOF
|
||||
[Unit]
|
||||
Description=Daily wg-ui TLS certificate renewal
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:17:00
|
||||
RandomizedDelaySec=1h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now wgui-acme-renew.timer
|
||||
}
|
||||
|
||||
install_openrc_timer() {
|
||||
local daily_dir=""
|
||||
|
||||
if [ -d /etc/periodic/daily ]; then
|
||||
daily_dir="/etc/periodic/daily"
|
||||
elif [ -d /etc/cron.daily ]; then
|
||||
daily_dir="/etc/cron.daily"
|
||||
fi
|
||||
|
||||
if [ -z "$daily_dir" ]; then
|
||||
log "warning: no daily cron directory found — run $ACME_HOME/acme.sh --cron daily yourself"
|
||||
return
|
||||
fi
|
||||
|
||||
local script="$daily_dir/wgui-acme-renew"
|
||||
log "writing certificate renewal script $script ..."
|
||||
cat > "$script" <<EOF
|
||||
#!/bin/sh
|
||||
set -a
|
||||
[ -f "$ACME_ENV_FILE" ] && . "$ACME_ENV_FILE"
|
||||
set +a
|
||||
export LE_WORKING_DIR="$ACME_HOME"
|
||||
exec "$ACME_HOME/acme.sh" --cron --home "$ACME_HOME"
|
||||
EOF
|
||||
chmod 755 "$script"
|
||||
}
|
||||
|
||||
require_root
|
||||
install_deps
|
||||
install_caddy
|
||||
install_acme_sh
|
||||
setup_caddy
|
||||
|
||||
INIT_SYSTEM="$(detect_init_system)"
|
||||
|
||||
case "$INIT_SYSTEM" in
|
||||
systemd)
|
||||
install_systemd_unit
|
||||
install_systemd_timer
|
||||
;;
|
||||
openrc)
|
||||
install_openrc_unit
|
||||
install_openrc_timer
|
||||
;;
|
||||
*)
|
||||
fail "no supported init system found — start Caddy manually with: $CADDY_BIN run --config $WGUI_LIB_DIR/caddy/Caddyfile --adapter caddyfile"
|
||||
;;
|
||||
esac
|
||||
|
||||
log "Caddy TLS ingress is installed and running on port $CADDY_HTTPS_PORT."
|
||||
log "Set the ACME email on each route form; certificate issuance uses manual DNS-01 from the route page."
|
||||
+35
-127
@@ -20,6 +20,14 @@ BRANCH="${BRANCH:-main}"
|
||||
BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}"
|
||||
BUN_VERSION="${BUN_VERSION:-1.4.3}"
|
||||
BUN_BIN="/usr/local/bin/bun"
|
||||
CADDY_VERSION="${CADDY_VERSION:-2.11.4}"
|
||||
CADDY_BIN_DIR="${CADDY_BIN_DIR:-/opt/caddy}"
|
||||
CADDY_BIN="$CADDY_BIN_DIR/caddy"
|
||||
CADDY_USER="${CADDY_USER:-wgui-caddy}"
|
||||
CADDY_HTTPS_PORT="${CADDY_HTTPS_PORT:-8443}"
|
||||
CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-8081}"
|
||||
ACME_HOME="${ACME_HOME:-/opt/acme.sh}"
|
||||
ACME_ENV_FILE="/etc/wgui/acme.env"
|
||||
UPDATE=false
|
||||
DEV_MODE=false
|
||||
if [ "${NODE_ENV:-}" = "development" ]; then
|
||||
@@ -79,16 +87,16 @@ install_deps() {
|
||||
debian | ubuntu | linuxmint | raspbian)
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y
|
||||
apt-get install -y git curl ca-certificates openssl zip unzip
|
||||
apt-get install -y git curl ca-certificates openssl zip unzip tar
|
||||
;;
|
||||
fedora | rhel | centos | rocky | almalinux | ol)
|
||||
dnf install -y git curl ca-certificates openssl zip unzip
|
||||
dnf install -y git curl ca-certificates openssl zip unzip tar
|
||||
;;
|
||||
arch | manjaro | endeavouros)
|
||||
pacman -Syu --noconfirm --needed git curl ca-certificates openssl zip unzip
|
||||
pacman -Syu --noconfirm --needed git curl ca-certificates openssl zip unzip tar
|
||||
;;
|
||||
alpine)
|
||||
apk add --no-cache bash curl ca-certificates-bundle git openssl zip unzip
|
||||
apk add --no-cache bash curl ca-certificates-bundle git openssl zip unzip tar
|
||||
;;
|
||||
*)
|
||||
fail "unsupported distro: $DISTRO"
|
||||
@@ -111,134 +119,29 @@ install_bun() {
|
||||
}
|
||||
|
||||
setup_lib_dirs() {
|
||||
mkdir -p "$WGUI_LIB_DIR/iptables" "$WGUI_LIB_DIR/keys" "$WGUI_LIB_DIR/clients" "$WGUI_LIB_DIR/hosts" "$WGUI_LIB_DIR/scripts"
|
||||
mkdir -p "$WGUI_LIB_DIR/iptables" "$WGUI_LIB_DIR/keys" "$WGUI_LIB_DIR/clients" "$WGUI_LIB_DIR/hosts" "$WGUI_LIB_DIR/scripts" "$WGUI_LIB_DIR/tls" "$WGUI_LIB_DIR/caddy/data"
|
||||
chmod 700 "$WGUI_LIB_DIR/iptables" "$WGUI_LIB_DIR/keys" "$WGUI_LIB_DIR/clients" "$WGUI_LIB_DIR/hosts"
|
||||
chmod 755 "$WGUI_LIB_DIR/scripts"
|
||||
chmod 755 "$WGUI_LIB_DIR"
|
||||
}
|
||||
|
||||
clone_or_update() {
|
||||
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
UPDATE=true
|
||||
log "updating existing install at $INSTALL_DIR ..."
|
||||
git -C "$INSTALL_DIR" fetch --depth 1 origin "$BRANCH"
|
||||
local db_backup="$INSTALL_DIR/db/.wgui-pre-update.db"
|
||||
if [ -f "$INSTALL_DIR/db/wgui" ]; then
|
||||
cp -a "$INSTALL_DIR/db/wgui" "$db_backup"
|
||||
fi
|
||||
git -C "$INSTALL_DIR" reset --hard "origin/$BRANCH"
|
||||
if [ -f "$db_backup" ]; then
|
||||
mv -f "$db_backup" "$INSTALL_DIR/db/wgui"
|
||||
fi
|
||||
else
|
||||
log "cloning wg-ui ($REPO_URL, branch $BRANCH) ..."
|
||||
git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
||||
install_caddy_stack() {
|
||||
local install_script="$INSTALL_DIR/src/scripts/install-caddy.sh"
|
||||
|
||||
if [ ! -f "$install_script" ]; then
|
||||
fail "missing $install_script — cannot install the Caddy TLS ingress"
|
||||
fi
|
||||
}
|
||||
|
||||
install_dependencies() {
|
||||
log "installing app dependencies with bun ..."
|
||||
cd "$INSTALL_DIR"
|
||||
"$BUN_BIN" install
|
||||
"$BUN_BIN" bun-sqlite schema
|
||||
}
|
||||
|
||||
ensure_env_file() {
|
||||
if [ ! -f "$INSTALL_DIR/.env" ]; then
|
||||
log "generating $INSTALL_DIR/.env with fresh encryption secrets ..."
|
||||
{
|
||||
echo "NODE_ENV=${NODE_ENV:-production}"
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -base64 32 | tr -d '\n')"
|
||||
echo "ENCRYPTION_SALT=$(openssl rand -base64 32 | tr -d '\n')"
|
||||
echo "DATA_DIR=$INSTALL_DIR/.data"
|
||||
} > "$INSTALL_DIR/.env"
|
||||
chmod 600 "$INSTALL_DIR/.env"
|
||||
fi
|
||||
}
|
||||
|
||||
setup_wireguard() {
|
||||
local setup_script="$INSTALL_DIR/src/scripts/setup-wireguard.sh"
|
||||
if [ -x "$setup_script" ] && [ "${SKIP_WIREGUARD_SETUP:-0}" != "1" ]; then
|
||||
log "running $(basename "$setup_script") to install wireguard on this host ..."
|
||||
"$setup_script"
|
||||
fi
|
||||
}
|
||||
|
||||
install_wg_quick_helper() {
|
||||
local helper_src="$INSTALL_DIR/src/scripts/wg-quick-manage.sh"
|
||||
if [ ! -f "$helper_src" ]; then
|
||||
fail "missing $helper_src — cannot install the wg-quick helper"
|
||||
fi
|
||||
log "installing wg-quick helper to $WG_QUICK_HELPER ..."
|
||||
install -m 755 -o root -g root "$helper_src" "$WG_QUICK_HELPER"
|
||||
}
|
||||
|
||||
install_systemd_unit() {
|
||||
local unit="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
log "writing systemd unit $unit ..."
|
||||
cat > "$unit" <<EOF
|
||||
[Unit]
|
||||
Description=Wireguard UI
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=$BUN_BIN src/server.ts
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStartSec=300
|
||||
PrivateTmp=true
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
log "enabling and starting $SERVICE_NAME ..."
|
||||
systemctl enable --now "$SERVICE_NAME.service"
|
||||
if [ "$UPDATE" = true ] && [ -z "${SKIP_SERVICE_RESTART:-}" ]; then
|
||||
log "restarting $SERVICE_NAME to load updated code ..."
|
||||
systemctl restart "$SERVICE_NAME.service"
|
||||
fi
|
||||
}
|
||||
|
||||
install_openrc_unit() {
|
||||
local init_script="/etc/init.d/$SERVICE_NAME"
|
||||
log "writing openrc init script $init_script ..."
|
||||
cat > "$init_script" <<EOF
|
||||
#!/sbin/openrc-run
|
||||
name="$SERVICE_NAME"
|
||||
description="Wireguard UI"
|
||||
command="$BUN_BIN"
|
||||
command_args="src/server.ts"
|
||||
command_user="root"
|
||||
directory="$INSTALL_DIR"
|
||||
output_log="/var/log/$SERVICE_NAME.log"
|
||||
error_log="/var/log/$SERVICE_NAME.log"
|
||||
pidfile="/run/\${RC_SVCNAME}.pid"
|
||||
export NODE_ENV="production"
|
||||
|
||||
start_pre() {
|
||||
umask 077
|
||||
}
|
||||
|
||||
depend() {
|
||||
need net
|
||||
}
|
||||
EOF
|
||||
chmod +x "$init_script"
|
||||
log "adding $SERVICE_NAME to default runlevel and starting ..."
|
||||
rc-update add "$SERVICE_NAME" default
|
||||
if [ "$UPDATE" = true ] && [ -z "${SKIP_SERVICE_RESTART:-}" ]; then
|
||||
rc-service "$SERVICE_NAME" restart
|
||||
else
|
||||
rc-service "$SERVICE_NAME" start
|
||||
fi
|
||||
log "installing/repairing the Caddy TLS ingress ..."
|
||||
WGUI_LIB_DIR="$WGUI_LIB_DIR" \
|
||||
CADDY_VERSION="$CADDY_VERSION" \
|
||||
CADDY_BIN_DIR="$CADDY_BIN_DIR" \
|
||||
CADDY_USER="$CADDY_USER" \
|
||||
CADDY_HTTPS_PORT="$CADDY_HTTPS_PORT" \
|
||||
CADDY_HTTP_PORT="$CADDY_HTTP_PORT" \
|
||||
ACME_HOME="$ACME_HOME" \
|
||||
ACME_ENV_FILE="$ACME_ENV_FILE" \
|
||||
bash "$install_script"
|
||||
}
|
||||
|
||||
grab_port() {
|
||||
@@ -297,6 +200,7 @@ install_bun
|
||||
setup_lib_dirs
|
||||
if [ "$DEV_MODE" = false ]; then
|
||||
clone_or_update
|
||||
install_caddy_stack
|
||||
fi
|
||||
install_dependencies
|
||||
ensure_env_file
|
||||
@@ -334,4 +238,8 @@ if [ "$DEV_MODE" = true ]; then
|
||||
else
|
||||
log "process: managed by $INIT_SYSTEM as $SERVICE_NAME"
|
||||
fi
|
||||
log "open http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):$PORT in your browser"
|
||||
log "open http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):$PORT in your browser"
|
||||
if [ "$DEV_MODE" = false ]; then
|
||||
log "tls ingress: wgui-caddy on https://<hostname>:$CADDY_HTTPS_PORT (never binds 80/443)"
|
||||
log "open a route page to issue certificates with manual DNS-01"
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user