Add TLS feature with Caddy

This commit is contained in:
2026-10-21 09:13:59 +01:00
parent 1462067e72
commit ed4a6f3ec8
109 changed files with 6374 additions and 156 deletions
@@ -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}
/>
);
}
+3 -1
View File
@@ -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,
+35
View File
@@ -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>
</>
@@ -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 };
}
@@ -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>
);
}
@@ -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`,
};
+28
View File
@@ -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>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import guardSetupPage from "../(functions)/guard-setup-page";
const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const redirect = await guardSetupPage({ req });
if (redirect) {
return { redirect };
}
return { props: {} };
};
export default server;
+27
View File
@@ -0,0 +1,27 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import useTlsStatus from "../(hooks)/use-tls-status";
import SetupPageFrame from "../(sections)/setup-page-frame";
import TlsStatusSection from "./(sections)/tls-status-section";
import TlsInstallSection from "./(sections)/tls-install-section";
import TlsSetupSection from "./(sections)/tls-setup-section";
export default function SetupTlsPage() {
const tls = useTlsStatus();
return (
<SetupPageFrame
title="TLS ingress"
description="Serve configured client routes over HTTPS without touching the host's ports 80 or 443."
>
<TlsStatusSection tls={tls} />
<TlsInstallSection tls={tls} />
<TlsSetupSection tls={tls} />
</SetupPageFrame>
);
}
export const meta: BunextPageModuleMeta = {
title: `TLS ingress | ${SiteData["SiteName"]}`,
description: `Caddy ingress and client-route certificates`,
};
@@ -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
+8
View File
@@ -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
+62
View File
@@ -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 };
}
+96
View File
@@ -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),
});
};
+37
View File
@@ -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,
});
};
+35
View File
@@ -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,
});
};
+35
View File
@@ -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,
};
}
};
+28
View File
@@ -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 },
};
};
-2
View File
@@ -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}
+367
View File
@@ -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
View File
@@ -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