Run the Caddy TLS ingress as a Docker container

Caddy previously ran as a host binary in /opt/caddy with a wgui-caddy
systemd/OpenRC unit. Only Caddy is containerized; the app still runs on the
host as root because it shells out to wg, ip and iptables.

- Add caddy-container-commands.ts as the single source of truth for the
  run/exec/validate/inspect argument arrays. Every value stays a separate
  argv element, the image is pinned to caddy:2.11.4-alpine, and the
  entrypoint is set explicitly because some mirrors publish the image with
  a null Entrypoint (which made Docker exec "run" as a binary).
- Rewrite install-caddy.sh: require_docker with distro-specific hints,
  pull + recreate the container, assert it is running and echo the daemon
  error instead of discarding it, normalize the admin socket path in
  existing Caddyfiles, and clean up a legacy native install only after the
  container is up so the ingress port is never unbound. Add
  --purge-legacy / --purge for hosts that want to drop the old install
  without installing the container.
- Move the admin socket to /var/lib/wgui/caddy/admin.sock so it lives
  inside the mounted Caddy dir and reloads work over docker exec.
- Validate candidate configs in a throwaway container, mounting the TLS
  dir read-only as well: provisioning the tls app loads every certificate
  referenced by a tls directive, so without that mount a route with a
  certificate failed validation and its Caddyfile was never committed.
- Report docker-based installed/version/service_active, skip
  validate/reload with a warning when the image is absent, and update the
  system setup tool check to look for docker.
- Issue certificates with a docker exec reload command and chown files to
  root:root, since the container reads them through a read-only mount.
- Update the Setup UI copy and docs (README, AGENTS.md).

Also drops the unused EmptyContent import and a stray debug log in the
client route form, and stops passing the unsupported cancel_href prop to
ClientRouteForm from the route add/edit pages.
This commit is contained in:
2026-10-21 14:34:16 +01:00
parent 2cc876b062
commit cbd5451ab7
22 changed files with 652 additions and 318 deletions
+2 -2
View File
@@ -26,7 +26,7 @@ When building, refactoring, styling, or auditing UI components and layouts, acti
## Entrypoint ## Entrypoint
`src/server.ts` — runs `bunextInit()`, starts `Bun.serve` on `0.0.0.0` (port from `SiteData.ServerPort`), serves `/media/*` via `handleMediaServer`, then runs `syncWireguardHosts()` to bring host tunnels in line with the DB. `src/server.ts` — runs `bunextInit()`, starts `Bun.serve` on `0.0.0.0` (port from `SiteData.ServerPort`), serves `/media/*` via `handleMediaServer`, then runs `syncWireguardHosts()` to bring host tunnels in line with the DB. Caddy's data plane runs as the `wgui-caddy` Docker container (host networking, `/var/lib/wgui/caddy` and `/var/lib/wgui/tls` bind-mounted); reloads go through `docker exec`, and the admin socket lives at `/var/lib/wgui/caddy/admin.sock`.
On-disk state: WireGuard keys/configs/iptables scripts live under `/var/lib/wgui` (`grabDirNames()` in `src/utils/grab-dir-names.ts`); uploaded media under `DATA_DIR` (default `./.data`). On-disk state: WireGuard keys/configs/iptables scripts live under `/var/lib/wgui` (`grabDirNames()` in `src/utils/grab-dir-names.ts`); uploaded media under `DATA_DIR` (default `./.data`).
@@ -82,7 +82,7 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
- **`src/hooks/`** — `use-admin-crud-get` (with `refetch`), `use-client-rules-map`, `use-confirm`, `use-toast`, `use-theme`, `use-fetch-get`, `use-form-init`, `use-users-get`. - **`src/hooks/`** — `use-admin-crud-get` (with `refetch`), `use-client-rules-map`, `use-confirm`, `use-toast`, `use-theme`, `use-fetch-get`, `use-form-init`, `use-users-get`.
- **`src/utils/`** — Cookies, crypto, dir names, IP/CIDR/port/interface validators, WireGuard interface naming. Most have `.test.ts` files. - **`src/utils/`** — Cookies, crypto, dir names, IP/CIDR/port/interface validators, WireGuard interface naming. Most have `.test.ts` files.
- **`src/dict/`**, **`src/data/`** — Constants (`app-data.ts`, `site-data.ts`), user types, client rules, links. - **`src/dict/`**, **`src/data/`** — Constants (`app-data.ts`, `site-data.ts`), user types, client rules, links.
- **`src/scripts/`** — Shell scripts: `install-wg-ui.sh`, `install-caddy.sh` (idempotent Caddy/acme ingress installer used by the UI and the full installer), `setup-wireguard.sh`, `wg-quick-manage.sh`. - **`src/scripts/`** — Shell scripts: `install-wg-ui.sh`, `install-caddy.sh` (idempotent installer that pulls the `caddy:<version>-alpine` image and (re)creates the `wgui-caddy` Docker container plus acme.sh renewal; used by the UI and the full installer), `setup-wireguard.sh`, `wg-quick-manage.sh`.
- **`src/components/general/`** — Shared app components, all built from twui. Reach for these before writing page markup: - **`src/components/general/`** — Shared app components, all built from twui. Reach for these before writing page markup:
- Page frame: `page-header` (title, meta, primary action + overflow menu), `page-body`, `panel` (the one card style), `form-section`, `form-actions` (submit is always rendered). - Page frame: `page-header` (title, meta, primary action + overflow menu), `page-body`, `panel` (the one card style), `form-section`, `form-actions` (submit is always rendered).
- Text & status: `muted-text`, `mono-text` (IPs, keys, paths), `section-label`, `status-dot`, `inline-alert`, `empty-state`, `stat-tile`, `key-value-list`, `copy-button`, `copyable-value`. - Text & status: `muted-text`, `mono-text` (IPs, keys, paths), `section-label`, `status-dot`, `inline-alert`, `empty-state`, `stat-tile`, `key-value-list`, `copy-button`, `copyable-value`.
+33 -7
View File
@@ -49,9 +49,9 @@ The install script will:
5. Install app dependencies (`bun install`) 5. Install app dependencies (`bun install`)
6. Generate `.env` with encryption secrets 6. Generate `.env` with encryption secrets
7. Run WireGuard setup (if available) 7. Run WireGuard setup (if available)
8. Install Caddy (`/opt/caddy`) 8. Install Docker (if missing) and run Caddy as the `wgui-caddy` container
9. Install the `wg-quick` helper script 9. Install the `wg-quick` helper script
10. Set up systemd/OpenRC services for `wgui`, `wgui-caddy`, and certificate renewal 10. Set up systemd/OpenRC services for `wgui` and certificate renewal
The installer **never modifies your firewall** — it will print the commands to The installer **never modifies your firewall** — it will print the commands to
temporarily allow the dashboard port so you can reach the first login, and then temporarily allow the dashboard port so you can reach the first login, and then
@@ -86,13 +86,16 @@ Firewall rules are managed per host, automatically:
### TLS for Tunnel Services ### TLS for Tunnel Services
Client routes expose internal services to WireGuard clients over HTTPS. The Client routes expose internal services to WireGuard clients over HTTPS. The
data plane is a dedicated Caddy service (`wgui-caddy`) on `8443`; wg-ui owns data plane is a dedicated Caddy container (`wgui-caddy`, host networking) on
the route and certificate records and regenerates the Caddyfile. Caddy never `8443`; wg-ui owns the route and certificate records and regenerates the
binds ports 80 or 443, and the host's existing proxy is left alone. Caddyfile. Caddy never binds ports 80 or 443, and the host's existing proxy is
left alone. Docker is required — the installer starts the daemon if it is not
running.
1. If Caddy isn't installed yet, open **Setup → TLS ingress** and click 1. If Caddy isn't installed yet, open **Setup → TLS ingress** and click
**Install Caddy** (or use the Caddy action on **Setup → Maintenance**). **Install Caddy** (or use the Caddy action on **Setup → Maintenance**).
The installer sets up the binary, the `wgui-caddy` service and certificate support. The installer pulls `caddy:2.11.4-alpine`, (re)creates the `wgui-caddy`
container, and sets up certificate support.
2. Open a route form from **Routes** (or a host's **Routes** panel) and set 2. Open a route form from **Routes** (or a host's **Routes** panel) and set
the ACME email and staging toggle. No DNS provider token is needed: the ACME email and staging toggle. No DNS provider token is needed:
@@ -117,7 +120,29 @@ binds ports 80 or 443, and the host's existing proxy is left alone.
Manual DNS-01 certificates are renewed from the route page because validation Manual DNS-01 certificates are renewed from the route page because validation
needs your registrar. The route list shows expiry warnings. A successful needs your registrar. The route list shows expiry warnings. A successful
issuance reloads Caddy without restarting wg-ui. Check issuance reloads Caddy without restarting wg-ui. Check
`journalctl -u wgui-caddy` for reload failures. `docker logs wgui-caddy` for reload failures.
#### Upgrading from the old native Caddy install
Earlier versions ran Caddy as a host binary in `/opt/caddy` with a `wgui-caddy`
systemd/OpenRC service. Re-running the installer migrates the host in place: it
pulls the image, starts the container, and only then removes the old service
and binary. The Caddyfile is rewritten to use the new admin socket path
(`/var/lib/wgui/caddy/admin.sock`); certificates in `/var/lib/wgui/tls` are
untouched.
To remove a leftover native install without installing the container:
```bash
sudo bash src/scripts/install-caddy.sh --purge-legacy
```
Add `--purge` to also remove the container, the image and the certificate
renewal job (the Caddyfile and certificates are always kept):
```bash
sudo bash src/scripts/install-caddy.sh --purge-legacy --purge
```
### Manual (Development) ### Manual (Development)
@@ -142,6 +167,7 @@ The `.env` file (gitignored) controls runtime behavior. It is auto-generated dur
| `ENCRYPTION_KEY` | Encryption key for sensitive data | Auto-generated | | `ENCRYPTION_KEY` | Encryption key for sensitive data | Auto-generated |
| `ENCRYPTION_SALT` | Encryption salt for sensitive data | Auto-generated | | `ENCRYPTION_SALT` | Encryption salt for sensitive data | Auto-generated |
| `DATA_DIR` | Application data directory | `$INSTALL_DIR/.data` | | `DATA_DIR` | Application data directory | `$INSTALL_DIR/.data` |
| `CADDY_VERSION` | Caddy image tag (`caddy:<version>-alpine`)| `2.11.4` |
| `CADDY_HTTPS_PORT`| Caddy ingress HTTPS port (never 80/443) | `8443` | | `CADDY_HTTPS_PORT`| Caddy ingress HTTPS port (never 80/443) | `8443` |
| `CADDY_HTTP_PORT` | Caddy plain HTTP port (never 80) | `8081` | | `CADDY_HTTP_PORT` | Caddy plain HTTP port (never 80) | `8081` |
| `CADDY_INGRESS_ENABLED` | Set to `0` to skip Caddy config sync | `1` | | `CADDY_INGRESS_ENABLED` | Set to `0` to skip Caddy config sync | `1` |
@@ -20,7 +20,6 @@ import { AppContext } from "@/src/pages/__root";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get"; import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import Select from "../../twui/form/Select"; import Select from "../../twui/form/Select";
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler"; import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
import EmptyContent from "../../twui/elements/EmptyContent";
import twuiNumberfy from "../../twui/utils/numberfy"; import twuiNumberfy from "../../twui/utils/numberfy";
type Props = { type Props = {
@@ -36,7 +35,9 @@ export default function ClientRouteForm({
const { user, pageProps } = useContext(AppContext); const { user, pageProps } = useContext(AppContext);
const [host_id, set_host_id] = useState( const [host_id, set_host_id] = useState(
passed_host_id || pageProps?.host?.id || pageProps?.hosts?.[0]?.id, typeof passed_host_id == "number"
? passed_host_id
: pageProps?.host?.id || pageProps?.hosts?.[0]?.id,
); );
const is_host_id_present = typeof host_id == "number"; const is_host_id_present = typeof host_id == "number";
@@ -65,12 +66,6 @@ export default function ClientRouteForm({
return <LoadingBlock className="h-[420px]" />; return <LoadingBlock className="h-[420px]" />;
} }
if (!pageProps?.hosts?.[0]) {
return (
<EmptyContent title="No hosts added. Please Add a host to continue" />
);
}
const cancel_href = is_host_id_present const cancel_href = is_host_id_present
? `/admin/hosts/${host_id}/routes` ? `/admin/hosts/${host_id}/routes`
: `/admin/routes`; : `/admin/routes`;
@@ -101,10 +96,14 @@ export default function ClientRouteForm({
/> />
) : null} ) : null}
<FormSection title="Host" description="The host for this route"> {pageProps?.hosts ? (
<FormSection
title="Host"
description="The host for this route"
>
<Select <Select
options={ options={
pageProps.hosts.map((h) => ({ pageProps?.hosts?.map((h) => ({
value: String(h.id), value: String(h.id),
default: h.id == host_id, default: h.id == host_id,
title: h.name, title: h.name,
@@ -115,6 +114,7 @@ export default function ClientRouteForm({
}} }}
/> />
</FormSection> </FormSection>
) : null}
<FormSection <FormSection
title="Route" title="Route"
description="The public hostname and path Caddy matches." description="The public hostname and path Caddy matches."
@@ -7,6 +7,10 @@ type Params = {
key_file: string; key_file: string;
}; };
/**
* Certificate files are consumed by the Caddy container through a read-only
* bind mount, so they only need to be readable by root.
*/
export default function fixCertificatePermissions({ export default function fixCertificatePermissions({
cert_dir, cert_dir,
cert_file, cert_file,
@@ -18,12 +22,12 @@ export default function fixCertificatePermissions({
chmodSync(key_file, 0o640); chmodSync(key_file, 0o640);
for (const target of [cert_dir, cert_file, key_file]) { for (const target of [cert_dir, cert_file, key_file]) {
execFileSync("chown", ["root:wgui-caddy", target], { execFileSync("chown", ["root:root", target], {
stdio: "pipe", stdio: "pipe",
timeout: 5000, timeout: 5000,
}); });
} }
} catch { } catch {
// The group may not exist in development; Caddy runs as root there. // Permissions are best-effort; the container only needs to read them.
} }
} }
@@ -5,8 +5,8 @@ import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import grabDirNames from "@/src/utils/grab-dir-names"; import grabDirNames from "@/src/utils/grab-dir-names";
import type { TableType } from "@/src/types"; 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 grabCertDirNames from "../caddy/grab-cert-dir-names";
import { grabCaddyReloadCommandString } from "../caddy/caddy-container-commands";
import syncCaddyIngress from "../caddy/sync-caddy-ingress"; import syncCaddyIngress from "../caddy/sync-caddy-ingress";
import validateRouteHostname from "../caddy/validate-route-hostname"; import validateRouteHostname from "../caddy/validate-route-hostname";
import grabCertificateByHostname from "./grab-certificate-by-hostname"; import grabCertificateByHostname from "./grab-certificate-by-hostname";
@@ -46,13 +46,13 @@ function fixPermissions({ cert_dir, cert_file, key_file }: {
chmodSync(key_file, 0o640); chmodSync(key_file, 0o640);
for (const target of [cert_dir, cert_file, key_file]) { for (const target of [cert_dir, cert_file, key_file]) {
execFileSync("chown", ["root:wgui-caddy", target], { execFileSync("chown", ["root:root", target], {
stdio: "pipe", stdio: "pipe",
timeout: 5000, timeout: 5000,
}); });
} }
} catch { } catch {
// The group may not exist in development; Caddy runs as root there. // Permissions are best-effort; the container only needs to read them.
} }
} }
@@ -148,11 +148,7 @@ export default async function issueCertificate({
mkdirSync(CERT_DIR, { recursive: true, mode: 0o750 }); mkdirSync(CERT_DIR, { recursive: true, mode: 0o750 });
const setup_status = grabSystemSetupStatus(); const reload_command = grabCaddyReloadCommandString();
const reload_command =
setup_status.init_system == "openrc"
? `rc-service wgui-caddy reload`
: `systemctl reload wgui-caddy`;
const child_env = { const child_env = {
...process.env, ...process.env,
@@ -8,7 +8,7 @@ import buildCaddyfile from "./build-caddyfile";
const base = { const base = {
https_port: 8443, https_port: 8443,
http_port: 8081, http_port: 8081,
admin_socket: `/run/wgui-caddy/admin.sock`, admin_socket: `/var/lib/wgui/caddy/admin.sock`,
storage_dir: `/var/lib/wgui/caddy/data`, storage_dir: `/var/lib/wgui/caddy/data`,
}; };
@@ -0,0 +1,116 @@
import { describe, expect, test } from "bun:test";
import grabDirNames from "@/src/utils/grab-dir-names";
import {
CADDY_CONTAINER_NAME,
CADDY_IMAGE,
grabCaddyInspectArgs,
grabCaddyReloadArgs,
grabCaddyReloadCommandString,
grabCaddyRestartArgs,
grabCaddyRunArgs,
grabCaddyValidateArgs,
} from "./caddy-container-commands";
const { WGUI_LIB_CADDY_DIR, WGUI_LIB_CADDY_CONFIG_FILE, WGUI_LIB_TLS_DIR } =
grabDirNames();
describe("caddy container commands", () => {
test("pins the container name and image", () => {
expect(CADDY_CONTAINER_NAME).toBe("wgui-caddy");
expect(CADDY_IMAGE).toBe(
`caddy:${process.env.CADDY_VERSION || "2.11.4"}-alpine`,
);
});
test("runs with host networking and mounts the caddy dir and certs", () => {
const args = grabCaddyRunArgs();
expect(args.slice(0, 3)).toEqual(["run", "-d", "--name"]);
expect(args).toContain("--network");
expect(args[args.indexOf("--network") + 1]).toBe("host");
expect(args).toContain(`${WGUI_LIB_CADDY_DIR}:${WGUI_LIB_CADDY_DIR}`);
expect(args).toContain(`${WGUI_LIB_TLS_DIR}:${WGUI_LIB_TLS_DIR}:ro`);
expect(args).toContain("--restart");
expect(args[args.indexOf("--restart") + 1]).toBe("unless-stopped");
expect(args).toContain(CADDY_IMAGE);
// The entrypoint must be explicit: mirrors exist that publish the image
// with a null Entrypoint, which would exec "run" as a binary.
expect(args).toContain("--entrypoint");
expect(args[args.indexOf("--entrypoint") + 1]).toBe("caddy");
expect(args[args.indexOf("--entrypoint") + 2]).toBe(CADDY_IMAGE);
expect(args.slice(-5)).toEqual([
"run",
"--config",
WGUI_LIB_CADDY_CONFIG_FILE,
"--adapter",
"caddyfile",
]);
});
test("reloads through docker exec with the unix admin socket", () => {
const args = grabCaddyReloadArgs();
expect(args.slice(0, 4)).toEqual([
"exec",
CADDY_CONTAINER_NAME,
"caddy",
"reload",
]);
expect(args).toContain("--config");
expect(args[args.indexOf("--config") + 1]).toBe(
WGUI_LIB_CADDY_CONFIG_FILE,
);
expect(args[args.indexOf("--address") + 1]).toBe(
`unix/${WGUI_LIB_CADDY_DIR}/admin.sock`,
);
});
test("restart fallback targets the container", () => {
expect(grabCaddyRestartArgs()).toEqual([
"restart",
CADDY_CONTAINER_NAME,
]);
});
test("validates in a throwaway container without networking", () => {
const config_path = `${WGUI_LIB_CADDY_DIR}/Caddyfile.tmp`;
const args = grabCaddyValidateArgs({ config_path });
expect(args.slice(0, 2)).toEqual(["run", "--rm"]);
expect(args).toContain("--network");
expect(args[args.indexOf("--network") + 1]).toBe("none");
expect(args).toContain("--entrypoint");
expect(args[args.indexOf("--entrypoint") + 1]).toBe("caddy");
expect(args).toContain(`${WGUI_LIB_CADDY_DIR}:${WGUI_LIB_CADDY_DIR}`);
// validate provisions the tls app, so route certificates must be
// visible or every config with a `tls` directive fails validation.
expect(args).toContain(`${WGUI_LIB_TLS_DIR}:${WGUI_LIB_TLS_DIR}:ro`);
expect(args).toContain(CADDY_IMAGE);
expect(args.slice(-5)).toEqual([
"validate",
"--config",
config_path,
"--adapter",
"caddyfile",
]);
});
test("inspect reads the running flag", () => {
expect(grabCaddyInspectArgs()).toEqual([
"inspect",
"--format",
"{{.State.Running}}",
CADDY_CONTAINER_NAME,
]);
});
test("the acme reload command mirrors the exec args", () => {
const command = grabCaddyReloadCommandString();
expect(command).toBe(
["docker", ...grabCaddyReloadArgs()].join(" "),
);
expect(command).not.toContain("systemctl");
expect(command).not.toContain("rc-service");
});
});
@@ -0,0 +1,141 @@
import grabDirNames from "@/src/utils/grab-dir-names";
/**
* Caddy runs as a Docker container instead of a host binary. Every argument is
* kept as its own array element — nothing here is interpolated into a shell
* string, except `grabCaddyReloadCommandString()` which is handed to acme.sh.
*
* The container uses host networking and mounts the Caddy state directory at
* the exact same path as the host, so the paths below are valid on both sides.
*/
const {
WGUI_LIB_CADDY_DIR,
WGUI_LIB_CADDY_CONFIG_FILE,
WGUI_LIB_CADDY_ADMIN_SOCKET,
WGUI_LIB_TLS_DIR,
} = grabDirNames();
export const CADDY_CONTAINER_NAME = `wgui-caddy`;
export const CADDY_VERSION = process.env.CADDY_VERSION || `2.11.4`;
export const CADDY_IMAGE = `caddy:${CADDY_VERSION}-alpine`;
export const CADDY_CONFIG_FILE_IN_CONTAINER = WGUI_LIB_CADDY_CONFIG_FILE;
export const CADDY_ADMIN_SOCKET_IN_CONTAINER = WGUI_LIB_CADDY_ADMIN_SOCKET;
function volumeArgs() {
return [
`-v`,
`${WGUI_LIB_CADDY_DIR}:${WGUI_LIB_CADDY_DIR}`,
`-v`,
`${WGUI_LIB_TLS_DIR}:${WGUI_LIB_TLS_DIR}:ro`,
];
}
export function grabCaddyRunArgs() {
return [
`run`,
`-d`,
`--name`,
CADDY_CONTAINER_NAME,
`--restart`,
`unless-stopped`,
`--network`,
`host`,
...volumeArgs(),
// Set the entrypoint explicitly instead of trusting the image metadata:
// some mirrors publish the image with a null Entrypoint, which would
// make Docker exec the first arg ("run") as a binary.
`--entrypoint`,
`caddy`,
CADDY_IMAGE,
`run`,
`--config`,
CADDY_CONFIG_FILE_IN_CONTAINER,
`--adapter`,
`caddyfile`,
];
}
export function grabCaddyReloadArgs() {
return [
`exec`,
CADDY_CONTAINER_NAME,
`caddy`,
`reload`,
`--config`,
CADDY_CONFIG_FILE_IN_CONTAINER,
`--adapter`,
`caddyfile`,
`--address`,
`unix/${CADDY_ADMIN_SOCKET_IN_CONTAINER}`,
];
}
export function grabCaddyRestartArgs() {
return [`restart`, CADDY_CONTAINER_NAME];
}
/**
* Validate a candidate Caddyfile in a throwaway container so a bad config can
* never take the running ingress down. The config must live inside the mounted
* Caddy directory for the container to see it.
*
* The TLS directory is mounted too: `caddy validate` provisions the tls app,
* which loads every certificate referenced by a `tls` directive, and a route
* whose certificate file is missing makes validation fail.
*/
export function grabCaddyValidateArgs({ config_path }: { config_path: string }) {
return [
`run`,
`--rm`,
`--network`,
`none`,
`--entrypoint`,
`caddy`,
...volumeArgs(),
CADDY_IMAGE,
`validate`,
`--config`,
config_path,
`--adapter`,
`caddyfile`,
];
}
export function grabCaddyInspectArgs() {
return [
`inspect`,
`--format`,
`{{.State.Running}}`,
CADDY_CONTAINER_NAME,
];
}
export function grabCaddyImageInspectArgs() {
return [`image`, `inspect`, CADDY_IMAGE];
}
export function grabCaddyExecVersionArgs() {
return [`exec`, CADDY_CONTAINER_NAME, `caddy`, `version`];
}
/**
* acme.sh runs `--reloadcmd` through a shell, so this one is a string. All
* values come from the constants above (no user input).
*/
export function grabCaddyReloadCommandString() {
return [
`docker`,
`exec`,
CADDY_CONTAINER_NAME,
`caddy`,
`reload`,
`--config`,
CADDY_CONFIG_FILE_IN_CONTAINER,
`--adapter`,
`caddyfile`,
`--address`,
`unix/${CADDY_ADMIN_SOCKET_IN_CONTAINER}`,
].join(` `);
}
@@ -0,0 +1,20 @@
import { execFileSync } from "node:child_process";
import { grabCaddyImageInspectArgs } from "./caddy-container-commands";
/**
* True when the Caddy image has been pulled. Until then the generated config
* cannot be validated or served (e.g. development machines without Docker).
*/
export default function caddyImageInstalled() {
try {
execFileSync("docker", grabCaddyImageInspectArgs(), {
encoding: "utf-8",
stdio: "pipe",
timeout: 5000,
});
return true;
} catch {
return false;
}
}
@@ -2,10 +2,15 @@ import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import grabDirNames from "@/src/utils/grab-dir-names"; import grabDirNames from "@/src/utils/grab-dir-names";
import { TlsData } from "@/src/data/tls-data"; import { TlsData } from "@/src/data/tls-data";
import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status"; import caddyImageInstalled from "./caddy-image-installed";
import {
CADDY_CONTAINER_NAME,
CADDY_IMAGE,
grabCaddyExecVersionArgs,
grabCaddyInspectArgs,
} from "./caddy-container-commands";
const { const {
WGUI_CADDY_BINARY,
WGUI_LIB_CADDY_CONFIG_FILE, WGUI_LIB_CADDY_CONFIG_FILE,
WGUI_LIB_CADDY_STATE_FILE, WGUI_LIB_CADDY_STATE_FILE,
WGUI_ACME_SCRIPT, WGUI_ACME_SCRIPT,
@@ -30,9 +35,9 @@ function readState() {
} }
} }
function version() { function docker({ args }: { args: string[] }) {
try { try {
return execFileSync(WGUI_CADDY_BINARY, ["version"], { return execFileSync("docker", args, {
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
timeout: 5000, timeout: 5000,
@@ -42,38 +47,28 @@ function version() {
} }
} }
function serviceActive() { function version() {
const setup_status = grabSystemSetupStatus(); const running = docker({ args: grabCaddyExecVersionArgs() });
try { if (running) {
if (setup_status.init_system == "systemd") { return running;
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;
} }
if (!caddyImageInstalled()) {
return null; return null;
}
return CADDY_IMAGE;
}
function serviceActive() {
const running = docker({ args: grabCaddyInspectArgs() });
if (running == null) {
return null;
}
return running == "true";
} }
type Settings = { type Settings = {
@@ -85,7 +80,10 @@ export default function grabCaddyStatus(settings?: Settings) {
const state = readState(); const state = readState();
return { return {
installed: existsSync(WGUI_CADDY_BINARY), runtime: "docker" as const,
container_name: CADDY_CONTAINER_NAME,
image: CADDY_IMAGE,
installed: caddyImageInstalled(),
service_active: serviceActive(), service_active: serviceActive(),
acme_installed: existsSync(WGUI_ACME_SCRIPT), acme_installed: existsSync(WGUI_ACME_SCRIPT),
acme_env_exists: existsSync(WGUI_ACME_ENV_FILE), acme_env_exists: existsSync(WGUI_ACME_ENV_FILE),
@@ -1,11 +1,12 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status"; import {
grabCaddyReloadArgs,
grabCaddyRestartArgs,
} from "./caddy-container-commands";
const CADDY_SERVICE_NAME = "wgui-caddy"; function run({ args }: { args: string[] }) {
function run({ command, args }: { command: string; args: string[] }) {
try { try {
const output = execFileSync(command, args, { const output = execFileSync("docker", args, {
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
timeout: 30000, timeout: 30000,
@@ -29,58 +30,24 @@ function run({ command, args }: { command: string; args: string[] }) {
} }
} }
function caddyServiceIsActive({ /**
init_system, * Reload the Caddy container's config over the admin socket. If the container
}: { * is missing (e.g. it was recreated or removed by hand), fall back to a restart
init_system: ReturnType<typeof grabSystemSetupStatus>["init_system"]; * so a fresh container picks up the new Caddyfile.
}) { */
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() { export default function reloadCaddyService() {
const setup_status = grabSystemSetupStatus(); const reload = run({ args: grabCaddyReloadArgs() });
const service_active = caddyServiceIsActive({
init_system: setup_status.init_system,
});
if (setup_status.is_dev && !service_active) { if (reload.success) {
return { return reload;
}
const restart = run({ args: grabCaddyRestartArgs() });
return restart.success
? {
success: true as const, success: true as const,
msg: `Development mode — Caddy service is not running, reload skipped`, msg: `Caddy container restarted`,
};
}
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`,
};
} }
: reload;
} }
@@ -18,6 +18,8 @@ import type { TableType } from "@/src/types";
import grabDirNames from "@/src/utils/grab-dir-names"; import grabDirNames from "@/src/utils/grab-dir-names";
import { TlsData } from "@/src/data/tls-data"; import { TlsData } from "@/src/data/tls-data";
import buildCaddyfile from "./build-caddyfile"; import buildCaddyfile from "./build-caddyfile";
import caddyImageInstalled from "./caddy-image-installed";
import { CADDY_IMAGE, grabCaddyValidateArgs } from "./caddy-container-commands";
import reloadCaddyService from "./reload-caddy-service"; import reloadCaddyService from "./reload-caddy-service";
import applyCaddyIngressFirewallRule from "./apply-caddy-ingress-firewall-rule"; import applyCaddyIngressFirewallRule from "./apply-caddy-ingress-firewall-rule";
import grabCaddySettings from "./grab-caddy-settings"; import grabCaddySettings from "./grab-caddy-settings";
@@ -33,7 +35,6 @@ type Params = {
}; };
const { const {
WGUI_CADDY_BINARY,
WGUI_LIB_CADDY_DIR, WGUI_LIB_CADDY_DIR,
WGUI_LIB_CADDY_CONFIG_FILE, WGUI_LIB_CADDY_CONFIG_FILE,
WGUI_LIB_CADDY_STATE_FILE, WGUI_LIB_CADDY_STATE_FILE,
@@ -155,29 +156,23 @@ async function doSync({ reason }: Params): Promise<CaddySyncResult> {
writeFileSync(tmp_path, built.config, { mode: 0o644 }); writeFileSync(tmp_path, built.config, { mode: 0o644 });
if (existsSync(WGUI_CADDY_BINARY)) { // Without the image the Caddyfile can be neither validated nor served —
// write it and let the installer/Setup page take over.
if (caddyImageInstalled()) {
try { try {
execFileSync( execFileSync(
WGUI_CADDY_BINARY, "docker",
[ grabCaddyValidateArgs({ config_path: tmp_path }),
"validate",
"--config",
tmp_path,
"--adapter",
"caddyfile",
],
{ {
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
timeout: 30000, timeout: 60000,
}, },
); );
} catch (error: any) { } catch (error: any) {
rmSync(tmp_path, { force: true }); rmSync(tmp_path, { force: true });
const msg = `Caddy rejected the generated config: ${ const msg = `Caddy rejected the generated config: ${
error?.stderr error?.stderr ? String(error.stderr).trim() : error.message
? String(error.stderr).trim()
: error.message
}`; }`;
writeState({ warnings: built.warnings, last_error: msg }); writeState({ warnings: built.warnings, last_error: msg });
@@ -188,6 +183,10 @@ async function doSync({ reason }: Params): Promise<CaddySyncResult> {
warnings: built.warnings, warnings: built.warnings,
}; };
} }
} else {
built.warnings.push(
`Caddy image ${CADDY_IMAGE} is not installed — config not validated or reloaded`,
);
} }
if (existsSync(WGUI_LIB_CADDY_CONFIG_FILE)) { if (existsSync(WGUI_LIB_CADDY_CONFIG_FILE)) {
@@ -196,7 +195,9 @@ async function doSync({ reason }: Params): Promise<CaddySyncResult> {
renameSync(tmp_path, WGUI_LIB_CADDY_CONFIG_FILE); renameSync(tmp_path, WGUI_LIB_CADDY_CONFIG_FILE);
const reload = reloadCaddyService(); const reload = caddyImageInstalled()
? reloadCaddyService()
: { success: true as const, msg: `skipped, image not installed` };
const warnings = [...built.warnings]; const warnings = [...built.warnings];
if (!reload.success) { if (!reload.success) {
@@ -85,9 +85,9 @@ const TOOL_CHECKS: {
version_command: "bun --version", version_command: "bun --version",
}, },
{ {
name: "caddy", name: "caddy (docker)",
command: "/opt/caddy/caddy", command: "docker",
version_command: "/opt/caddy/caddy version", version_command: "docker --version",
}, },
]; ];
@@ -22,10 +22,7 @@ export default function AddClientRoutePage() {
}} }}
/> />
<PageBody> <PageBody>
<ClientRouteForm <ClientRouteForm host_id={host_id} />
host_id={host_id}
cancel_href={`/admin/hosts/${host_id}/routes`}
/>
</PageBody> </PageBody>
</> </>
); );
@@ -28,7 +28,6 @@ export default function EditClientRoutePage() {
<ClientRouteForm <ClientRouteForm
host_id={host_id} host_id={host_id}
existing_route={route} existing_route={route}
cancel_href={`/admin/hosts/${host_id}/routes`}
/> />
) : ( ) : (
<EmptyState <EmptyState
@@ -13,13 +13,13 @@ export default function SetupCaddyAction({ installed, on_done }: Props) {
return ( return (
<SetupActionRow <SetupActionRow
title="Caddy TLS ingress" 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." description="Pulls the Caddy image and (re)creates the wgui-caddy container with host networking. Requires Docker. Certificate issuance is handled by wg-ui with manual DNS-01. Safe to re-run."
icon={<ServerCog size={15} />} icon={<ServerCog size={15} />}
button_title={installed ? "Repair Caddy" : "Install Caddy"} button_title={installed ? "Repair Caddy" : "Install Caddy"}
confirm_params={{ confirm_params={{
title: installed ? "Repair the Caddy ingress?" : "Install Caddy?", title: installed ? "Repair the Caddy ingress?" : "Install Caddy?",
message: message:
"This installs Caddy and the wgui-caddy service. Existing routes and certificates are preserved.", "This pulls the Caddy image and restarts the wgui-caddy container. Existing routes and certificates are preserved.",
confirm_label: installed ? "Repair Caddy" : "Install Caddy", confirm_label: installed ? "Repair Caddy" : "Install Caddy",
}} }}
on_run={async () => { on_run={async () => {
@@ -26,7 +26,7 @@ export default function SetupActionsSection({ setup }: Props) {
.every((tool) => tool.installed); .every((tool) => tool.installed);
const have_caddy = Boolean( const have_caddy = Boolean(
setup_status.tools.find((tool) => tool.command == "/opt/caddy/caddy") setup_status.tools.find((tool) => tool.name.startsWith("caddy"))
?.installed, ?.installed,
); );
@@ -18,7 +18,7 @@ export default function TlsStatusSection({ tls }: Props) {
return ( return (
<Panel <Panel
title="Ingress" title="Ingress"
description="Caddy runs as its own service and terminates TLS on the configured high port." description="Caddy runs as a Docker container (host networking) and terminates TLS on the configured high port."
actions={ actions={
<> <>
<StatusDot <StatusDot
@@ -33,7 +33,7 @@ export default function TlsStatusSection({ tls }: Props) {
} }
label={ label={
!caddy?.installed !caddy?.installed
? "Caddy not installed" ? "Caddy image missing"
: caddy.service_active === false : caddy.service_active === false
? "wgui-caddy stopped" ? "wgui-caddy stopped"
: caddy.version || "Caddy installed" : caddy.version || "Caddy installed"
@@ -70,7 +70,7 @@ export default function TlsStatusSection({ tls }: Props) {
{!caddy?.installed ? ( {!caddy?.installed ? (
<InlineAlert <InlineAlert
tone="warning" tone="warning"
msg="Caddy is not installed yet. Run the installer/update script, or install it to /opt/caddy/caddy." msg="The Caddy image is not present yet. Click “Install Caddy” below — Docker is required."
/> />
) : null} ) : null}
{caddy?.state?.last_error ? ( {caddy?.state?.last_error ? (
@@ -94,6 +94,14 @@ export default function TlsStatusSection({ tls }: Props) {
? `${caddy.https_port}/tcp` ? `${caddy.https_port}/tcp`
: "—", : "—",
}, },
{
key: "runtime",
label: "Runtime",
value: caddy
? `docker · ${caddy.container_name} · ${caddy.image}`
: "—",
mono: true,
},
{ {
key: "config", key: "config",
label: "Caddyfile", label: "Caddyfile",
+1 -7
View File
@@ -11,12 +11,7 @@ import type {
const INSTALL_CADDY_SCRIPT = `install-caddy.sh`; const INSTALL_CADDY_SCRIPT = `install-caddy.sh`;
const INSTALL_CADDY_TIMEOUT_MS = 20 * 60 * 1000; const INSTALL_CADDY_TIMEOUT_MS = 20 * 60 * 1000;
const { const { WGUI_LIB_DIR, WGUI_ACME_HOME, WGUI_ACME_ENV_FILE } = grabDirNames();
WGUI_LIB_DIR,
WGUI_CADDY_BIN_DIR,
WGUI_ACME_HOME,
WGUI_ACME_ENV_FILE,
} = grabDirNames();
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req, req,
@@ -37,7 +32,6 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
env: { env: {
WGUI_LIB_DIR, WGUI_LIB_DIR,
CADDY_VERSION: process.env.CADDY_VERSION, CADDY_VERSION: process.env.CADDY_VERSION,
CADDY_BIN_DIR: WGUI_CADDY_BIN_DIR,
CADDY_HTTPS_PORT: String(TlsData.CaddyHttpsPort), CADDY_HTTPS_PORT: String(TlsData.CaddyHttpsPort),
CADDY_HTTP_PORT: String(TlsData.CaddyHttpPort), CADDY_HTTP_PORT: String(TlsData.CaddyHttpPort),
ACME_HOME: WGUI_ACME_HOME, ACME_HOME: WGUI_ACME_HOME,
+210 -139
View File
@@ -1,6 +1,7 @@
#!/bin/bash #!/bin/bash
# Install or repair the Caddy TLS ingress used by client routes. # Install or repair the Caddy TLS ingress used by client routes.
# Caddy runs as a Docker container (host networking) instead of a host binary.
# Safe to re-run: it preserves the existing Caddyfile and certificate data. # Safe to re-run: it preserves the existing Caddyfile and certificate data.
set -euo pipefail set -euo pipefail
@@ -10,14 +11,15 @@ fail() { echo "error: $*" >&2; exit 1; }
WGUI_LIB_DIR="${WGUI_LIB_DIR:-/var/lib/wgui}" WGUI_LIB_DIR="${WGUI_LIB_DIR:-/var/lib/wgui}"
CADDY_VERSION="${CADDY_VERSION:-2.11.4}" CADDY_VERSION="${CADDY_VERSION:-2.11.4}"
CADDY_BIN_DIR="${CADDY_BIN_DIR:-/opt/caddy}" CADDY_IMAGE="caddy:${CADDY_VERSION}-alpine"
CADDY_BIN="$CADDY_BIN_DIR/caddy" CADDY_CONTAINER_NAME="wgui-caddy"
CADDY_USER="${CADDY_USER:-wgui-caddy}" CADDY_DIR="$WGUI_LIB_DIR/caddy"
CADDY_CONFIG_FILE="$CADDY_DIR/Caddyfile"
CADDY_ADMIN_SOCKET="$CADDY_DIR/admin.sock"
CADDY_HTTPS_PORT="${CADDY_HTTPS_PORT:-8443}" CADDY_HTTPS_PORT="${CADDY_HTTPS_PORT:-8443}"
CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-8081}" CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-8081}"
ACME_HOME="${ACME_HOME:-/opt/acme.sh}" ACME_HOME="${ACME_HOME:-/opt/acme.sh}"
ACME_ENV_FILE="${ACME_ENV_FILE:-/etc/wgui/acme.env}" ACME_ENV_FILE="${ACME_ENV_FILE:-/etc/wgui/acme.env}"
SERVICE_NAME="wgui-caddy"
require_root() { require_root() {
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
@@ -45,19 +47,32 @@ detect_init_system() {
fi fi
} }
caddy_arch() { docker_install_hint() {
case "$(uname -m)" in local distro
x86_64 | amd64) echo "amd64" ;; distro="$(detect_distro)"
aarch64 | arm64 | armv8l | armv8) echo "arm64" ;;
armv7l | armv7) echo "armv7" ;; case "$distro" in
armv6l | armv6) echo "armv6" ;; debian | ubuntu | linuxmint | raspbian)
*) fail "unsupported architecture for Caddy: $(uname -m)" ;; echo "apt-get install -y docker.io"
;;
fedora | rhel | centos | rocky | almalinux | ol)
echo "dnf install -y docker"
;;
arch | manjaro | endeavouros)
echo "pacman -S --noconfirm docker"
;;
alpine)
echo "apk add docker"
;;
*)
echo "install Docker from https://docs.docker.com/engine/install/"
;;
esac esac
} }
install_deps() { install_deps() {
local missing=false
local tool local tool
local missing=false
for tool in curl tar git openssl; do for tool in curl tar git openssl; do
if ! command -v "$tool" >/dev/null 2>&1; then if ! command -v "$tool" >/dev/null 2>&1; then
@@ -97,50 +112,36 @@ install_deps() {
esac esac
} }
install_caddy() { require_docker() {
if [ -x "$CADDY_BIN" ]; then if ! command -v docker >/dev/null 2>&1; then
if [ ! -e /usr/local/bin/caddy ]; then fail "docker is required for the Caddy ingress — install it with: $(docker_install_hint)"
ln -sf "$CADDY_BIN" /usr/local/bin/caddy
fi
log "caddy: $($CADDY_BIN version)"
return
fi fi
local arch local init_system
arch="$(caddy_arch)" init_system="$(detect_init_system)"
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} ..." if ! docker info >/dev/null 2>&1; then
mkdir -p "$CADDY_BIN_DIR" log "starting the docker daemon ..."
curl -fsSL -o "$tmp_dir/$archive" "$base_url/$archive" case "$init_system" in
curl -fsSL -o "$tmp_dir/checksums.txt" "$base_url/caddy_${CADDY_VERSION}_checksums.txt" systemd)
systemctl enable --now docker
if command -v sha512sum >/dev/null 2>&1; then ;;
(cd "$tmp_dir" && grep " $archive\$" checksums.txt | sha512sum -c -) openrc)
elif command -v openssl >/dev/null 2>&1; then rc-update add docker default
local expected actual rc-service docker start
expected="$(grep " $archive\$" "$tmp_dir/checksums.txt" | awk '{print $1}')" ;;
actual="$(openssl dgst -sha512 "$tmp_dir/$archive" | awk '{print $NF}')" *)
fail "docker is installed but not running — start it manually and re-run this script"
if [ -z "$expected" ] || [ "$expected" != "$actual" ]; then ;;
fail "Caddy checksum verification failed for $archive" esac
fi
else
fail "no sha512 verification tool available (install coreutils or openssl)"
fi fi
tar -xzf "$tmp_dir/$archive" -C "$CADDY_BIN_DIR" caddy if ! docker info >/dev/null 2>&1; then
chmod 755 "$CADDY_BIN" fail "docker is not running — start it and re-run this script"
if [ ! -e /usr/local/bin/caddy ]; then
ln -sf "$CADDY_BIN" /usr/local/bin/caddy
fi fi
rm -rf "$tmp_dir"
log "caddy: $($CADDY_BIN version)" log "docker: $(docker --version)"
} }
install_acme_sh() { install_acme_sh() {
@@ -156,29 +157,15 @@ install_acme_sh() {
chmod 700 "$ACME_HOME/acme.sh" chmod 700 "$ACME_HOME/acme.sh"
} }
setup_caddy() { setup_caddy_dirs() {
if ! id -u "$CADDY_USER" >/dev/null 2>&1; then # The official Caddy image runs as root, so the mounted state directory is
log "creating service user $CADDY_USER ..." # root-owned; nothing else on the host needs access to it.
if command -v useradd >/dev/null 2>&1; then mkdir -p "$CADDY_DIR/data" "$WGUI_LIB_DIR/tls" /etc/wgui
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" chmod 755 "$WGUI_LIB_DIR"
chown root:"$CADDY_USER" "$WGUI_LIB_DIR/tls" chmod 750 "$CADDY_DIR"
chmod 750 "$CADDY_DIR/data"
chmod 750 "$WGUI_LIB_DIR/tls" chmod 750 "$WGUI_LIB_DIR/tls"
chown -R "$CADDY_USER:$CADDY_USER" "$WGUI_LIB_DIR/caddy" chown -R root:root "$CADDY_DIR" "$WGUI_LIB_DIR/tls"
chmod 750 "$WGUI_LIB_DIR/caddy"
chmod 750 "$WGUI_LIB_DIR/caddy/data"
mkdir -p /etc/wgui
chmod 700 /etc/wgui chmod 700 /etc/wgui
if [ ! -f "$ACME_ENV_FILE" ]; then if [ ! -f "$ACME_ENV_FILE" ]; then
@@ -194,89 +181,134 @@ EOF
chown root:root "$ACME_ENV_FILE" chown root:root "$ACME_ENV_FILE"
fi fi
if [ ! -f "$WGUI_LIB_DIR/caddy/Caddyfile" ]; then if [ ! -f "$CADDY_CONFIG_FILE" ]; then
log "writing initial Caddyfile ..." log "writing initial Caddyfile ..."
cat > "$WGUI_LIB_DIR/caddy/Caddyfile" <<EOF cat > "$CADDY_CONFIG_FILE" <<EOF
{ {
https_port $CADDY_HTTPS_PORT https_port $CADDY_HTTPS_PORT
http_port $CADDY_HTTP_PORT http_port $CADDY_HTTP_PORT
auto_https disable_redirects auto_https disable_redirects
admin unix//run/wgui-caddy/admin.sock admin unix/$CADDY_ADMIN_SOCKET
storage file_system $WGUI_LIB_DIR/caddy/data storage file_system $CADDY_DIR/data
} }
EOF EOF
chown "$CADDY_USER:$CADDY_USER" "$WGUI_LIB_DIR/caddy/Caddyfile" chmod 640 "$CADDY_CONFIG_FILE"
chmod 640 "$WGUI_LIB_DIR/caddy/Caddyfile" chown root:root "$CADDY_CONFIG_FILE"
fi fi
} }
install_systemd_unit() { # Older installs used an admin socket under /run, which does not exist inside
local unit="/etc/systemd/system/$SERVICE_NAME.service" # the container. Point an existing Caddyfile at the mounted socket path.
log "writing systemd unit $unit ..." normalize_caddyfile() {
cat > "$unit" <<EOF [ -f "$CADDY_CONFIG_FILE" ] || return
[Unit]
Description=wg-ui TLS ingress (Caddy)
After=network-online.target
Wants=network-online.target
[Service] if grep -qx " admin unix/$CADDY_ADMIN_SOCKET" "$CADDY_CONFIG_FILE"; then
Type=simple return
User=$CADDY_USER fi
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] log "updating the admin socket path in the existing Caddyfile ..."
WantedBy=multi-user.target local tmp
EOF tmp="$(mktemp)"
systemctl daemon-reload awk -v admin=" admin unix/$CADDY_ADMIN_SOCKET" '
systemctl enable "$SERVICE_NAME.service" BEGIN { inserted = 0 }
systemctl restart "$SERVICE_NAME.service" /^[[:space:]]*admin / { next }
{
print
if (!inserted && $0 ~ /^[[:space:]]*\{/) {
print admin
inserted = 1
}
}
END {
if (!inserted) print admin
}
' "$CADDY_CONFIG_FILE" > "$tmp"
chmod 640 "$tmp"
chown root:root "$tmp"
mv "$tmp" "$CADDY_CONFIG_FILE"
} }
install_openrc_unit() { install_container() {
local init_script="/etc/init.d/$SERVICE_NAME" log "pulling $CADDY_IMAGE ..."
log "writing openrc init script $init_script ..." docker pull "$CADDY_IMAGE"
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() { if docker inspect "$CADDY_CONTAINER_NAME" >/dev/null 2>&1; then
mkdir -p /run/wgui-caddy log "removing the existing $CADDY_CONTAINER_NAME container ..."
chown "$CADDY_USER:$CADDY_USER" /run/wgui-caddy docker rm -f "$CADDY_CONTAINER_NAME" >/dev/null
fi
log "starting the $CADDY_CONTAINER_NAME container ..."
local run_output=""
# The entrypoint is set explicitly: some mirrors publish the image with a
# null Entrypoint, which would make Docker exec the first arg as a binary.
if ! run_output="$(docker run -d \
--name "$CADDY_CONTAINER_NAME" \
--restart unless-stopped \
--network host \
-v "$CADDY_DIR:$CADDY_DIR" \
-v "$WGUI_LIB_DIR/tls:$WGUI_LIB_DIR/tls:ro" \
--entrypoint caddy \
"$CADDY_IMAGE" \
run --config "$CADDY_CONFIG_FILE" --adapter caddyfile 2>&1)"; then
fail "could not start the $CADDY_CONTAINER_NAME container: $run_output"
fi
local running
running="$(docker inspect --format '{{.State.Running}}' "$CADDY_CONTAINER_NAME" 2>/dev/null || echo false)"
if [ "$running" != "true" ]; then
docker logs --tail 50 "$CADDY_CONTAINER_NAME" >&2 || true
fail "the $CADDY_CONTAINER_NAME container failed to start"
fi
log "caddy: $(docker exec "$CADDY_CONTAINER_NAME" caddy version 2>/dev/null || echo "$CADDY_IMAGE")"
} }
reload() { # Remove a native Caddy install left over from earlier versions so it does not
ebegin "Reloading Caddy" # keep holding the ingress ports.
"$CADDY_BIN" reload --config "$WGUI_LIB_DIR/caddy/Caddyfile" --adapter caddyfile --address unix//run/wgui-caddy/admin.sock cleanup_legacy_local_install() {
eend \$? local init_system
} init_system="$(detect_init_system)"
local legacy_unit="/etc/systemd/system/wgui-caddy.service"
local legacy_openrc="/etc/init.d/wgui-caddy"
depend() { if [ -f "$legacy_unit" ]; then
need net log "removing the legacy wgui-caddy systemd unit ..."
} systemctl disable --now wgui-caddy >/dev/null 2>&1 || true
EOF rm -f "$legacy_unit"
chmod +x "$init_script" systemctl daemon-reload >/dev/null 2>&1 || true
rc-update add "$SERVICE_NAME" default fi
rc-service "$SERVICE_NAME" restart
if [ -f "$legacy_openrc" ]; then
log "removing the legacy wgui-caddy init script ..."
rc-service wgui-caddy stop >/dev/null 2>&1 || true
rc-update del wgui-caddy default >/dev/null 2>&1 || true
rm -f "$legacy_openrc"
fi
if [ -d /opt/caddy ]; then
log "removing the legacy Caddy binary from /opt/caddy ..."
rm -rf /opt/caddy
fi
if [ -L /usr/local/bin/caddy ]; then
local target
target="$(readlink /usr/local/bin/caddy || echo '')"
if [ "$target" = "/opt/caddy/caddy" ]; then
rm -f /usr/local/bin/caddy
fi
fi
# The legacy admin socket lived in /run and would otherwise be stale.
rm -rf /run/wgui-caddy
if [ "$init_system" = "unknown" ]; then
log "warning: no supported init system found — the container still starts on boot via docker"
fi
} }
install_systemd_timer() { install_systemd_timer() {
@@ -341,27 +373,66 @@ EOF
chmod 755 "$script" chmod 755 "$script"
} }
PURGE_LEGACY=false
PURGE_ALL=false
for arg in "$@"; do
case "$arg" in
--purge-legacy) PURGE_LEGACY=true ;;
--purge) PURGE_ALL=true ;;
*) fail "unknown argument: $arg" ;;
esac
done
require_root require_root
# --purge-legacy: remove a leftover native install (binary, service, socket)
# without touching Docker, the Caddyfile or any certificate.
if [ "$PURGE_LEGACY" = true ]; then
log "purging the legacy native Caddy install ..."
cleanup_legacy_local_install
if [ "$PURGE_ALL" = true ]; then
log "purging the Caddy container, images and renewal job ..."
docker rm -f "$CADDY_CONTAINER_NAME" >/dev/null 2>&1 || true
docker rmi "$CADDY_IMAGE" >/dev/null 2>&1 || true
rm -f /etc/systemd/system/wgui-acme-renew.service \
/etc/systemd/system/wgui-acme-renew.timer \
/etc/periodic/daily/wgui-acme-renew \
/etc/cron.daily/wgui-acme-renew
systemctl daemon-reload >/dev/null 2>&1 || true
log "kept: $CADDY_DIR (Caddyfile + certificates) and $WGUI_LIB_DIR/tls"
fi
log "legacy Caddy cleanup complete."
exit 0
fi
install_deps install_deps
install_caddy require_docker
install_acme_sh install_acme_sh
setup_caddy setup_caddy_dirs
normalize_caddyfile
INIT_SYSTEM="$(detect_init_system)" INIT_SYSTEM="$(detect_init_system)"
case "$INIT_SYSTEM" in case "$INIT_SYSTEM" in
systemd) systemd)
install_systemd_unit
install_systemd_timer install_systemd_timer
;; ;;
openrc) openrc)
install_openrc_unit
install_openrc_timer install_openrc_timer
;; ;;
*) *)
fail "no supported init system found — start Caddy manually with: $CADDY_BIN run --config $WGUI_LIB_DIR/caddy/Caddyfile --adapter caddyfile" log "warning: no supported init system found — set up certificate renewal manually"
;; ;;
esac esac
log "Caddy TLS ingress is installed and running on port $CADDY_HTTPS_PORT." # Start the container before removing the legacy service so the ingress port is
# never left unbound.
install_container
cleanup_legacy_local_install
log "Caddy TLS ingress container '$CADDY_CONTAINER_NAME' is running on port $CADDY_HTTPS_PORT."
log "Inspect it with: docker logs $CADDY_CONTAINER_NAME"
log "Set the ACME email on each route form; certificate issuance uses manual DNS-01 from the route page." log "Set the ACME email on each route form; certificate issuance uses manual DNS-01 from the route page."
+2 -7
View File
@@ -21,9 +21,6 @@ BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}"
BUN_VERSION="${BUN_VERSION:-1.4.3}" BUN_VERSION="${BUN_VERSION:-1.4.3}"
BUN_BIN="/usr/local/bin/bun" BUN_BIN="/usr/local/bin/bun"
CADDY_VERSION="${CADDY_VERSION:-2.11.4}" 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_HTTPS_PORT="${CADDY_HTTPS_PORT:-8443}"
CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-8081}" CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-8081}"
ACME_HOME="${ACME_HOME:-/opt/acme.sh}" ACME_HOME="${ACME_HOME:-/opt/acme.sh}"
@@ -132,11 +129,9 @@ install_caddy_stack() {
fail "missing $install_script — cannot install the Caddy TLS ingress" fail "missing $install_script — cannot install the Caddy TLS ingress"
fi fi
log "installing/repairing the Caddy TLS ingress ..." log "installing/repairing the Caddy TLS ingress container ..."
WGUI_LIB_DIR="$WGUI_LIB_DIR" \ WGUI_LIB_DIR="$WGUI_LIB_DIR" \
CADDY_VERSION="$CADDY_VERSION" \ CADDY_VERSION="$CADDY_VERSION" \
CADDY_BIN_DIR="$CADDY_BIN_DIR" \
CADDY_USER="$CADDY_USER" \
CADDY_HTTPS_PORT="$CADDY_HTTPS_PORT" \ CADDY_HTTPS_PORT="$CADDY_HTTPS_PORT" \
CADDY_HTTP_PORT="$CADDY_HTTP_PORT" \ CADDY_HTTP_PORT="$CADDY_HTTP_PORT" \
ACME_HOME="$ACME_HOME" \ ACME_HOME="$ACME_HOME" \
@@ -240,6 +235,6 @@ else
fi 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 if [ "$DEV_MODE" = false ]; then
log "tls ingress: wgui-caddy on https://<hostname>:$CADDY_HTTPS_PORT (never binds 80/443)" log "tls ingress: wgui-caddy container on https://<hostname>:$CADDY_HTTPS_PORT (never binds 80/443); inspect with: docker logs wgui-caddy"
log "open a route page to issue certificates with manual DNS-01" log "open a route page to issue certificates with manual DNS-01"
fi fi
+6 -5
View File
@@ -45,9 +45,12 @@ export default function grabDirNames(params?: Params) {
`state.json`, `state.json`,
); );
const WGUI_LIB_CADDY_DATA_DIR = path.join(WGUI_LIB_CADDY_DIR, `data`); const WGUI_LIB_CADDY_DATA_DIR = path.join(WGUI_LIB_CADDY_DIR, `data`);
const WGUI_LIB_CADDY_ADMIN_SOCKET = `/run/wgui-caddy/admin.sock`; // Caddy runs in a container that mounts the Caddy dir at the same path, so
const WGUI_CADDY_BIN_DIR = `/opt/caddy`; // the admin socket lives next to the Caddyfile.
const WGUI_CADDY_BINARY = path.join(WGUI_CADDY_BIN_DIR, `caddy`); const WGUI_LIB_CADDY_ADMIN_SOCKET = path.join(
WGUI_LIB_CADDY_DIR,
`admin.sock`,
);
const WGUI_ACME_HOME = `/opt/acme.sh`; const WGUI_ACME_HOME = `/opt/acme.sh`;
const WGUI_ACME_SCRIPT = path.join(WGUI_ACME_HOME, `acme.sh`); const WGUI_ACME_SCRIPT = path.join(WGUI_ACME_HOME, `acme.sh`);
const WGUI_ACME_ENV_FILE = `/etc/wgui/acme.env`; const WGUI_ACME_ENV_FILE = `/etc/wgui/acme.env`;
@@ -87,8 +90,6 @@ export default function grabDirNames(params?: Params) {
WGUI_LIB_CADDY_STATE_FILE, WGUI_LIB_CADDY_STATE_FILE,
WGUI_LIB_CADDY_DATA_DIR, WGUI_LIB_CADDY_DATA_DIR,
WGUI_LIB_CADDY_ADMIN_SOCKET, WGUI_LIB_CADDY_ADMIN_SOCKET,
WGUI_CADDY_BIN_DIR,
WGUI_CADDY_BINARY,
WGUI_ACME_HOME, WGUI_ACME_HOME,
WGUI_ACME_SCRIPT, WGUI_ACME_SCRIPT,
WGUI_ACME_ENV_FILE, WGUI_ACME_ENV_FILE,