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:
@@ -26,7 +26,7 @@ When building, refactoring, styling, or auditing UI components and layouts, acti
|
||||
|
||||
## 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`).
|
||||
|
||||
@@ -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/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/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:
|
||||
- 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`.
|
||||
|
||||
@@ -49,9 +49,9 @@ The install script will:
|
||||
5. Install app dependencies (`bun install`)
|
||||
6. Generate `.env` with encryption secrets
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
the route and certificate records and regenerates the Caddyfile. Caddy never
|
||||
binds ports 80 or 443, and the host's existing proxy is left alone.
|
||||
data plane is a dedicated Caddy container (`wgui-caddy`, host networking) on
|
||||
`8443`; wg-ui owns the route and certificate records and regenerates the
|
||||
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
|
||||
**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
|
||||
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
|
||||
needs your registrar. The route list shows expiry warnings. A successful
|
||||
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)
|
||||
|
||||
@@ -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_SALT` | Encryption salt for sensitive data | Auto-generated |
|
||||
| `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_HTTP_PORT` | Caddy plain HTTP port (never 80) | `8081` |
|
||||
| `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 Select from "../../twui/form/Select";
|
||||
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
|
||||
import EmptyContent from "../../twui/elements/EmptyContent";
|
||||
import twuiNumberfy from "../../twui/utils/numberfy";
|
||||
|
||||
type Props = {
|
||||
@@ -36,7 +35,9 @@ export default function ClientRouteForm({
|
||||
const { user, pageProps } = useContext(AppContext);
|
||||
|
||||
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";
|
||||
@@ -65,12 +66,6 @@ export default function ClientRouteForm({
|
||||
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
|
||||
? `/admin/hosts/${host_id}/routes`
|
||||
: `/admin/routes`;
|
||||
@@ -101,10 +96,14 @@ export default function ClientRouteForm({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FormSection title="Host" description="The host for this route">
|
||||
{pageProps?.hosts ? (
|
||||
<FormSection
|
||||
title="Host"
|
||||
description="The host for this route"
|
||||
>
|
||||
<Select
|
||||
options={
|
||||
pageProps.hosts.map((h) => ({
|
||||
pageProps?.hosts?.map((h) => ({
|
||||
value: String(h.id),
|
||||
default: h.id == host_id,
|
||||
title: h.name,
|
||||
@@ -115,6 +114,7 @@ export default function ClientRouteForm({
|
||||
}}
|
||||
/>
|
||||
</FormSection>
|
||||
) : null}
|
||||
<FormSection
|
||||
title="Route"
|
||||
description="The public hostname and path Caddy matches."
|
||||
|
||||
@@ -7,6 +7,10 @@ type Params = {
|
||||
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({
|
||||
cert_dir,
|
||||
cert_file,
|
||||
@@ -18,12 +22,12 @@ export default function fixCertificatePermissions({
|
||||
chmodSync(key_file, 0o640);
|
||||
|
||||
for (const target of [cert_dir, cert_file, key_file]) {
|
||||
execFileSync("chown", ["root:wgui-caddy", target], {
|
||||
execFileSync("chown", ["root:root", target], {
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
} 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 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 { grabCaddyReloadCommandString } from "../caddy/caddy-container-commands";
|
||||
import syncCaddyIngress from "../caddy/sync-caddy-ingress";
|
||||
import validateRouteHostname from "../caddy/validate-route-hostname";
|
||||
import grabCertificateByHostname from "./grab-certificate-by-hostname";
|
||||
@@ -46,13 +46,13 @@ function fixPermissions({ cert_dir, cert_file, key_file }: {
|
||||
chmodSync(key_file, 0o640);
|
||||
|
||||
for (const target of [cert_dir, cert_file, key_file]) {
|
||||
execFileSync("chown", ["root:wgui-caddy", target], {
|
||||
execFileSync("chown", ["root:root", target], {
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
} 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 });
|
||||
|
||||
const setup_status = grabSystemSetupStatus();
|
||||
const reload_command =
|
||||
setup_status.init_system == "openrc"
|
||||
? `rc-service wgui-caddy reload`
|
||||
: `systemctl reload wgui-caddy`;
|
||||
const reload_command = grabCaddyReloadCommandString();
|
||||
|
||||
const child_env = {
|
||||
...process.env,
|
||||
|
||||
@@ -8,7 +8,7 @@ import buildCaddyfile from "./build-caddyfile";
|
||||
const base = {
|
||||
https_port: 8443,
|
||||
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`,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 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";
|
||||
import caddyImageInstalled from "./caddy-image-installed";
|
||||
import {
|
||||
CADDY_CONTAINER_NAME,
|
||||
CADDY_IMAGE,
|
||||
grabCaddyExecVersionArgs,
|
||||
grabCaddyInspectArgs,
|
||||
} from "./caddy-container-commands";
|
||||
|
||||
const {
|
||||
WGUI_CADDY_BINARY,
|
||||
WGUI_LIB_CADDY_CONFIG_FILE,
|
||||
WGUI_LIB_CADDY_STATE_FILE,
|
||||
WGUI_ACME_SCRIPT,
|
||||
@@ -30,9 +35,9 @@ function readState() {
|
||||
}
|
||||
}
|
||||
|
||||
function version() {
|
||||
function docker({ args }: { args: string[] }) {
|
||||
try {
|
||||
return execFileSync(WGUI_CADDY_BINARY, ["version"], {
|
||||
return execFileSync("docker", args, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
@@ -42,38 +47,28 @@ function version() {
|
||||
}
|
||||
}
|
||||
|
||||
function serviceActive() {
|
||||
const setup_status = grabSystemSetupStatus();
|
||||
function version() {
|
||||
const running = docker({ args: grabCaddyExecVersionArgs() });
|
||||
|
||||
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;
|
||||
if (running) {
|
||||
return running;
|
||||
}
|
||||
|
||||
if (!caddyImageInstalled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CADDY_IMAGE;
|
||||
}
|
||||
|
||||
function serviceActive() {
|
||||
const running = docker({ args: grabCaddyInspectArgs() });
|
||||
|
||||
if (running == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return running == "true";
|
||||
}
|
||||
|
||||
type Settings = {
|
||||
@@ -85,7 +80,10 @@ export default function grabCaddyStatus(settings?: Settings) {
|
||||
const state = readState();
|
||||
|
||||
return {
|
||||
installed: existsSync(WGUI_CADDY_BINARY),
|
||||
runtime: "docker" as const,
|
||||
container_name: CADDY_CONTAINER_NAME,
|
||||
image: CADDY_IMAGE,
|
||||
installed: caddyImageInstalled(),
|
||||
service_active: serviceActive(),
|
||||
acme_installed: existsSync(WGUI_ACME_SCRIPT),
|
||||
acme_env_exists: existsSync(WGUI_ACME_ENV_FILE),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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({ command, args }: { command: string; args: string[] }) {
|
||||
function run({ args }: { args: string[] }) {
|
||||
try {
|
||||
const output = execFileSync(command, args, {
|
||||
const output = execFileSync("docker", args, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 30000,
|
||||
@@ -29,58 +30,24 @@ function run({ command, args }: { command: string; args: string[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* so a fresh container picks up the new Caddyfile.
|
||||
*/
|
||||
export default function reloadCaddyService() {
|
||||
const setup_status = grabSystemSetupStatus();
|
||||
const service_active = caddyServiceIsActive({
|
||||
init_system: setup_status.init_system,
|
||||
});
|
||||
const reload = run({ args: grabCaddyReloadArgs() });
|
||||
|
||||
if (setup_status.is_dev && !service_active) {
|
||||
return {
|
||||
if (reload.success) {
|
||||
return reload;
|
||||
}
|
||||
|
||||
const restart = run({ args: grabCaddyRestartArgs() });
|
||||
|
||||
return restart.success
|
||||
? {
|
||||
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`,
|
||||
};
|
||||
msg: `Caddy container restarted`,
|
||||
}
|
||||
: reload;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ 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 caddyImageInstalled from "./caddy-image-installed";
|
||||
import { CADDY_IMAGE, grabCaddyValidateArgs } from "./caddy-container-commands";
|
||||
import reloadCaddyService from "./reload-caddy-service";
|
||||
import applyCaddyIngressFirewallRule from "./apply-caddy-ingress-firewall-rule";
|
||||
import grabCaddySettings from "./grab-caddy-settings";
|
||||
@@ -33,7 +35,6 @@ type Params = {
|
||||
};
|
||||
|
||||
const {
|
||||
WGUI_CADDY_BINARY,
|
||||
WGUI_LIB_CADDY_DIR,
|
||||
WGUI_LIB_CADDY_CONFIG_FILE,
|
||||
WGUI_LIB_CADDY_STATE_FILE,
|
||||
@@ -155,29 +156,23 @@ async function doSync({ reason }: Params): Promise<CaddySyncResult> {
|
||||
|
||||
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 {
|
||||
execFileSync(
|
||||
WGUI_CADDY_BINARY,
|
||||
[
|
||||
"validate",
|
||||
"--config",
|
||||
tmp_path,
|
||||
"--adapter",
|
||||
"caddyfile",
|
||||
],
|
||||
"docker",
|
||||
grabCaddyValidateArgs({ config_path: tmp_path }),
|
||||
{
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 30000,
|
||||
timeout: 60000,
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
rmSync(tmp_path, { force: true });
|
||||
const msg = `Caddy rejected the generated config: ${
|
||||
error?.stderr
|
||||
? String(error.stderr).trim()
|
||||
: error.message
|
||||
error?.stderr ? String(error.stderr).trim() : error.message
|
||||
}`;
|
||||
|
||||
writeState({ warnings: built.warnings, last_error: msg });
|
||||
@@ -188,6 +183,10 @@ async function doSync({ reason }: Params): Promise<CaddySyncResult> {
|
||||
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)) {
|
||||
@@ -196,7 +195,9 @@ async function doSync({ reason }: Params): Promise<CaddySyncResult> {
|
||||
|
||||
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];
|
||||
|
||||
if (!reload.success) {
|
||||
|
||||
@@ -85,9 +85,9 @@ const TOOL_CHECKS: {
|
||||
version_command: "bun --version",
|
||||
},
|
||||
{
|
||||
name: "caddy",
|
||||
command: "/opt/caddy/caddy",
|
||||
version_command: "/opt/caddy/caddy version",
|
||||
name: "caddy (docker)",
|
||||
command: "docker",
|
||||
version_command: "docker --version",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -22,10 +22,7 @@ export default function AddClientRoutePage() {
|
||||
}}
|
||||
/>
|
||||
<PageBody>
|
||||
<ClientRouteForm
|
||||
host_id={host_id}
|
||||
cancel_href={`/admin/hosts/${host_id}/routes`}
|
||||
/>
|
||||
<ClientRouteForm host_id={host_id} />
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,6 @@ export default function EditClientRoutePage() {
|
||||
<ClientRouteForm
|
||||
host_id={host_id}
|
||||
existing_route={route}
|
||||
cancel_href={`/admin/hosts/${host_id}/routes`}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
|
||||
@@ -13,13 +13,13 @@ 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."
|
||||
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} />}
|
||||
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.",
|
||||
"This pulls the Caddy image and restarts the wgui-caddy container. Existing routes and certificates are preserved.",
|
||||
confirm_label: installed ? "Repair Caddy" : "Install Caddy",
|
||||
}}
|
||||
on_run={async () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function SetupActionsSection({ setup }: Props) {
|
||||
.every((tool) => tool.installed);
|
||||
|
||||
const have_caddy = Boolean(
|
||||
setup_status.tools.find((tool) => tool.command == "/opt/caddy/caddy")
|
||||
setup_status.tools.find((tool) => tool.name.startsWith("caddy"))
|
||||
?.installed,
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function TlsStatusSection({ tls }: Props) {
|
||||
return (
|
||||
<Panel
|
||||
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={
|
||||
<>
|
||||
<StatusDot
|
||||
@@ -33,7 +33,7 @@ export default function TlsStatusSection({ tls }: Props) {
|
||||
}
|
||||
label={
|
||||
!caddy?.installed
|
||||
? "Caddy not installed"
|
||||
? "Caddy image missing"
|
||||
: caddy.service_active === false
|
||||
? "wgui-caddy stopped"
|
||||
: caddy.version || "Caddy installed"
|
||||
@@ -70,7 +70,7 @@ export default function TlsStatusSection({ tls }: Props) {
|
||||
{!caddy?.installed ? (
|
||||
<InlineAlert
|
||||
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}
|
||||
{caddy?.state?.last_error ? (
|
||||
@@ -94,6 +94,14 @@ export default function TlsStatusSection({ tls }: Props) {
|
||||
? `${caddy.https_port}/tcp`
|
||||
: "—",
|
||||
},
|
||||
{
|
||||
key: "runtime",
|
||||
label: "Runtime",
|
||||
value: caddy
|
||||
? `docker · ${caddy.container_name} · ${caddy.image}`
|
||||
: "—",
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: "config",
|
||||
label: "Caddyfile",
|
||||
|
||||
@@ -11,12 +11,7 @@ import type {
|
||||
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();
|
||||
const { WGUI_LIB_DIR, WGUI_ACME_HOME, WGUI_ACME_ENV_FILE } = grabDirNames();
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
@@ -37,7 +32,6 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
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,
|
||||
|
||||
+210
-139
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 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.
|
||||
|
||||
set -euo pipefail
|
||||
@@ -10,14 +11,15 @@ 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_IMAGE="caddy:${CADDY_VERSION}-alpine"
|
||||
CADDY_CONTAINER_NAME="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_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
|
||||
@@ -45,19 +47,32 @@ detect_init_system() {
|
||||
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)" ;;
|
||||
docker_install_hint() {
|
||||
local distro
|
||||
distro="$(detect_distro)"
|
||||
|
||||
case "$distro" in
|
||||
debian | ubuntu | linuxmint | raspbian)
|
||||
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
|
||||
}
|
||||
|
||||
install_deps() {
|
||||
local missing=false
|
||||
local tool
|
||||
local missing=false
|
||||
|
||||
for tool in curl tar git openssl; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
@@ -97,50 +112,36 @@ install_deps() {
|
||||
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
|
||||
require_docker() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
fail "docker is required for the Caddy ingress — install it with: $(docker_install_hint)"
|
||||
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)"
|
||||
local init_system
|
||||
init_system="$(detect_init_system)"
|
||||
|
||||
log "installing Caddy ${CADDY_VERSION} (${arch}) to ${CADDY_BIN_DIR} ..."
|
||||
mkdir -p "$CADDY_BIN_DIR"
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
log "starting the docker daemon ..."
|
||||
|
||||
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)"
|
||||
case "$init_system" in
|
||||
systemd)
|
||||
systemctl enable --now docker
|
||||
;;
|
||||
openrc)
|
||||
rc-update add docker default
|
||||
rc-service docker start
|
||||
;;
|
||||
*)
|
||||
fail "docker is installed but not running — start it manually and re-run this script"
|
||||
;;
|
||||
esac
|
||||
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
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
fail "docker is not running — start it and re-run this script"
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
|
||||
log "caddy: $($CADDY_BIN version)"
|
||||
log "docker: $(docker --version)"
|
||||
}
|
||||
|
||||
install_acme_sh() {
|
||||
@@ -156,29 +157,15 @@ install_acme_sh() {
|
||||
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"
|
||||
setup_caddy_dirs() {
|
||||
# The official Caddy image runs as root, so the mounted state directory is
|
||||
# root-owned; nothing else on the host needs access to it.
|
||||
mkdir -p "$CADDY_DIR/data" "$WGUI_LIB_DIR/tls" /etc/wgui
|
||||
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"
|
||||
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
|
||||
chown -R root:root "$CADDY_DIR" "$WGUI_LIB_DIR/tls"
|
||||
chmod 700 /etc/wgui
|
||||
|
||||
if [ ! -f "$ACME_ENV_FILE" ]; then
|
||||
@@ -194,89 +181,134 @@ EOF
|
||||
chown root:root "$ACME_ENV_FILE"
|
||||
fi
|
||||
|
||||
if [ ! -f "$WGUI_LIB_DIR/caddy/Caddyfile" ]; then
|
||||
if [ ! -f "$CADDY_CONFIG_FILE" ]; then
|
||||
log "writing initial Caddyfile ..."
|
||||
cat > "$WGUI_LIB_DIR/caddy/Caddyfile" <<EOF
|
||||
cat > "$CADDY_CONFIG_FILE" <<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
|
||||
admin unix/$CADDY_ADMIN_SOCKET
|
||||
storage file_system $CADDY_DIR/data
|
||||
}
|
||||
EOF
|
||||
chown "$CADDY_USER:$CADDY_USER" "$WGUI_LIB_DIR/caddy/Caddyfile"
|
||||
chmod 640 "$WGUI_LIB_DIR/caddy/Caddyfile"
|
||||
chmod 640 "$CADDY_CONFIG_FILE"
|
||||
chown root:root "$CADDY_CONFIG_FILE"
|
||||
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
|
||||
# Older installs used an admin socket under /run, which does not exist inside
|
||||
# the container. Point an existing Caddyfile at the mounted socket path.
|
||||
normalize_caddyfile() {
|
||||
[ -f "$CADDY_CONFIG_FILE" ] || return
|
||||
|
||||
[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
|
||||
if grep -qx " admin unix/$CADDY_ADMIN_SOCKET" "$CADDY_CONFIG_FILE"; then
|
||||
return
|
||||
fi
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
log "updating the admin socket path in the existing Caddyfile ..."
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME.service"
|
||||
systemctl restart "$SERVICE_NAME.service"
|
||||
awk -v admin=" admin unix/$CADDY_ADMIN_SOCKET" '
|
||||
BEGIN { inserted = 0 }
|
||||
/^[[: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() {
|
||||
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"
|
||||
install_container() {
|
||||
log "pulling $CADDY_IMAGE ..."
|
||||
docker pull "$CADDY_IMAGE"
|
||||
|
||||
start_pre() {
|
||||
mkdir -p /run/wgui-caddy
|
||||
chown "$CADDY_USER:$CADDY_USER" /run/wgui-caddy
|
||||
if docker inspect "$CADDY_CONTAINER_NAME" >/dev/null 2>&1; then
|
||||
log "removing the existing $CADDY_CONTAINER_NAME container ..."
|
||||
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() {
|
||||
ebegin "Reloading Caddy"
|
||||
"$CADDY_BIN" reload --config "$WGUI_LIB_DIR/caddy/Caddyfile" --adapter caddyfile --address unix//run/wgui-caddy/admin.sock
|
||||
eend \$?
|
||||
}
|
||||
# Remove a native Caddy install left over from earlier versions so it does not
|
||||
# keep holding the ingress ports.
|
||||
cleanup_legacy_local_install() {
|
||||
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() {
|
||||
need net
|
||||
}
|
||||
EOF
|
||||
chmod +x "$init_script"
|
||||
rc-update add "$SERVICE_NAME" default
|
||||
rc-service "$SERVICE_NAME" restart
|
||||
if [ -f "$legacy_unit" ]; then
|
||||
log "removing the legacy wgui-caddy systemd unit ..."
|
||||
systemctl disable --now wgui-caddy >/dev/null 2>&1 || true
|
||||
rm -f "$legacy_unit"
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
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() {
|
||||
@@ -341,27 +373,66 @@ EOF
|
||||
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
|
||||
|
||||
# --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_caddy
|
||||
require_docker
|
||||
install_acme_sh
|
||||
setup_caddy
|
||||
setup_caddy_dirs
|
||||
normalize_caddyfile
|
||||
|
||||
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"
|
||||
log "warning: no supported init system found — set up certificate renewal manually"
|
||||
;;
|
||||
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."
|
||||
|
||||
@@ -21,9 +21,6 @@ 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}"
|
||||
@@ -132,11 +129,9 @@ install_caddy_stack() {
|
||||
fail "missing $install_script — cannot install the Caddy TLS ingress"
|
||||
fi
|
||||
|
||||
log "installing/repairing the Caddy TLS ingress ..."
|
||||
log "installing/repairing the Caddy TLS ingress container ..."
|
||||
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" \
|
||||
@@ -240,6 +235,6 @@ else
|
||||
fi
|
||||
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 "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"
|
||||
fi
|
||||
@@ -45,9 +45,12 @@ export default function grabDirNames(params?: Params) {
|
||||
`state.json`,
|
||||
);
|
||||
const WGUI_LIB_CADDY_DATA_DIR = path.join(WGUI_LIB_CADDY_DIR, `data`);
|
||||
const WGUI_LIB_CADDY_ADMIN_SOCKET = `/run/wgui-caddy/admin.sock`;
|
||||
const WGUI_CADDY_BIN_DIR = `/opt/caddy`;
|
||||
const WGUI_CADDY_BINARY = path.join(WGUI_CADDY_BIN_DIR, `caddy`);
|
||||
// Caddy runs in a container that mounts the Caddy dir at the same path, so
|
||||
// the admin socket lives next to the Caddyfile.
|
||||
const WGUI_LIB_CADDY_ADMIN_SOCKET = path.join(
|
||||
WGUI_LIB_CADDY_DIR,
|
||||
`admin.sock`,
|
||||
);
|
||||
const WGUI_ACME_HOME = `/opt/acme.sh`;
|
||||
const WGUI_ACME_SCRIPT = path.join(WGUI_ACME_HOME, `acme.sh`);
|
||||
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_DATA_DIR,
|
||||
WGUI_LIB_CADDY_ADMIN_SOCKET,
|
||||
WGUI_CADDY_BIN_DIR,
|
||||
WGUI_CADDY_BINARY,
|
||||
WGUI_ACME_HOME,
|
||||
WGUI_ACME_SCRIPT,
|
||||
WGUI_ACME_ENV_FILE,
|
||||
|
||||
Reference in New Issue
Block a user