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
@@ -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,