@yawlabs/caddy-mcp 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -3
- package/dist/index.js +196 -54
- package/dist/server.js +196 -54
- package/dist/snapshots.d.ts +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,12 +78,29 @@ That's it. Now ask your AI assistant:
|
|
|
78
78
|
|
|
79
79
|
| Environment variable | Default | Description |
|
|
80
80
|
|---|---|---|
|
|
81
|
-
| `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL. Set to `http://caddy:2019` inside Docker, or an https URL for remote admin. |
|
|
81
|
+
| `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL. Set to `http://caddy:2019` inside Docker, or an https URL for remote admin. Also accepts a unix socket, in either `unix:///var/run/caddy-admin.sock` or Caddy's own `unix//var/run/caddy-admin.sock` spelling — see below. |
|
|
82
82
|
| `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints. Only needed if you've configured Caddy with auth. |
|
|
83
|
+
| `CADDY_MCP_SNAPSHOT_DIR` | (none) | Directory for persisting `caddy_revert` snapshots. Unset, snapshots live in memory only and are lost when this server restarts. Snapshots are full Caddy configs and can contain secrets, so the location is opt-in rather than defaulted. |
|
|
83
84
|
| `CADDY_MAX_RETRIES` | `2` | Number of retries on transient failures (5xx, network errors). 4xx and 412 never retry. POSTs to `/config/*` and `/id/*` also skip retry (non-idempotent appends/creates -- retrying could duplicate routes or 409 a half-applied create). POSTs to `/load`, `/adapt`, `/stop` still retry. Hard-capped at 5; values above the cap log a one-time stderr notice so the clamp is visible. Set to `0` to disable. |
|
|
84
85
|
| `CADDY_TIMEOUT` | `10000` | Timeout in ms for all admin API requests except `/load` (which uses `CADDY_LOAD_TIMEOUT`). Non-numeric, `<= 0`, or fractional values below 1ms fall back to the default. |
|
|
85
86
|
| `CADDY_LOAD_TIMEOUT` | `60000` | Timeout in ms for the `/load` endpoint; raise for ACME-heavy bring-ups where provisioning many certificates can exceed the default. Non-numeric, `<= 0`, or fractional values below 1ms fall back to the default. |
|
|
86
87
|
|
|
88
|
+
**Unix socket admin endpoints:**
|
|
89
|
+
|
|
90
|
+
Caddy's recommended hardening is to move the admin API off a loopback port and
|
|
91
|
+
onto a unix socket, where access is governed by filesystem permissions:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
{
|
|
95
|
+
admin unix//var/run/caddy-admin.sock
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Point `CADDY_ADMIN_URL` at the same path (`unix:///var/run/caddy-admin.sock`)
|
|
100
|
+
and requests are sent over the socket instead of TCP. The process running
|
|
101
|
+
caddy-mcp needs read/write permission on the socket file. `CADDY_API_TOKEN`
|
|
102
|
+
still applies if you have auth in front of the endpoint.
|
|
103
|
+
|
|
87
104
|
**Alternate MCP clients:**
|
|
88
105
|
|
|
89
106
|
| Client | Config file |
|
|
@@ -217,6 +234,17 @@ Browsable read-only data — MCP clients can fetch these directly without a tool
|
|
|
217
234
|
- You have `admin.listen` or `admin.origins` restrictions set in your Caddy config, or you're missing an `Authorization` header.
|
|
218
235
|
- Set `CADDY_API_TOKEN` in your MCP config env if Caddy expects a Bearer token.
|
|
219
236
|
|
|
237
|
+
**`SIGUSR1` / `systemctl reload caddy` stops reloading the Caddyfile**
|
|
238
|
+
|
|
239
|
+
- Expected, and not caused by a bug here. Since Caddy 2.11.1, `SIGUSR1` reloads
|
|
240
|
+
from the file on disk **only if the config has never been changed through the
|
|
241
|
+
admin API**. The first write from caddy-mcp (or any other API client) makes
|
|
242
|
+
Caddy consider the running config API-owned, and `SIGUSR1` becomes a no-op.
|
|
243
|
+
- Pick one owner per instance. If the Caddyfile is the source of truth, use
|
|
244
|
+
caddy-mcp read-only tools (`caddy_status`, `caddy_list_routes`, `caddy_adapt`)
|
|
245
|
+
and reload from the file. If caddy-mcp owns the config, apply changes with
|
|
246
|
+
`caddy_load` instead of `SIGUSR1`.
|
|
247
|
+
|
|
220
248
|
**Windows: MCP server doesn't start**
|
|
221
249
|
|
|
222
250
|
- Use the `cmd /c npx ...` pattern from the Quick start section. Node 20+ can't spawn `.cmd` files directly.
|
|
@@ -224,7 +252,9 @@ Browsable read-only data — MCP clients can fetch these directly without a tool
|
|
|
224
252
|
## Requirements
|
|
225
253
|
|
|
226
254
|
- Node.js 20+
|
|
227
|
-
- Caddy 2.x with admin API enabled (default: `localhost:2019`)
|
|
255
|
+
- Caddy 2.x with admin API enabled (default: `localhost:2019`). Verified against
|
|
256
|
+
Caddy 2.11.4; the `@id` write path relies on `PATCH` semantics that the live
|
|
257
|
+
integration suite pins per release.
|
|
228
258
|
|
|
229
259
|
## Contributing
|
|
230
260
|
|
|
@@ -235,7 +265,7 @@ npm install
|
|
|
235
265
|
npm run lint # Biome check
|
|
236
266
|
npm run lint:fix # Auto-fix
|
|
237
267
|
npm run build # tsup bundle
|
|
238
|
-
npm test # Vitest (
|
|
268
|
+
npm test # Vitest (357 unit tests, +9 POSIX-only unix-socket tests; +13 live-Caddy integration tests gated by CADDY_MCP_INTEGRATION=1)
|
|
239
269
|
npm run typecheck # tsc --noEmit
|
|
240
270
|
```
|
|
241
271
|
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
6
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
7
|
|
|
8
8
|
// src/api.ts
|
|
9
|
+
import { request as httpRequest } from "http";
|
|
9
10
|
var DEFAULT_URL = "http://localhost:2019";
|
|
10
11
|
var TIMEOUT = 1e4;
|
|
11
12
|
var RETRY_BASE_MS = 100;
|
|
@@ -45,6 +46,15 @@ function invalidateRelated(path) {
|
|
|
45
46
|
function getBaseUrl() {
|
|
46
47
|
return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
|
|
47
48
|
}
|
|
49
|
+
function getUnixSocketPath() {
|
|
50
|
+
const raw = (process.env.CADDY_ADMIN_URL || "").trim();
|
|
51
|
+
if (!raw) return void 0;
|
|
52
|
+
const urlForm = /^unix:\/\/(\/.*)$/.exec(raw);
|
|
53
|
+
if (urlForm) return urlForm[1];
|
|
54
|
+
const caddyForm = /^unix\/(\/.*)$/.exec(raw);
|
|
55
|
+
if (caddyForm) return caddyForm[1];
|
|
56
|
+
return void 0;
|
|
57
|
+
}
|
|
48
58
|
var warnedRetryClamp = false;
|
|
49
59
|
function getMaxRetries() {
|
|
50
60
|
const raw = process.env.CADDY_MAX_RETRIES;
|
|
@@ -65,11 +75,12 @@ function getAdminOrigin() {
|
|
|
65
75
|
return void 0;
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
|
-
function getHeaders(contentType) {
|
|
78
|
+
function getHeaders(contentType, overUnixSocket = false) {
|
|
69
79
|
const headers = {};
|
|
70
80
|
if (contentType) headers["Content-Type"] = contentType;
|
|
71
81
|
const token = process.env.CADDY_API_TOKEN;
|
|
72
82
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
83
|
+
if (overUnixSocket) return headers;
|
|
73
84
|
const origin = getAdminOrigin();
|
|
74
85
|
if (origin) headers.Origin = origin;
|
|
75
86
|
return headers;
|
|
@@ -102,39 +113,85 @@ function isRetryableMethod(method, path) {
|
|
|
102
113
|
if (method !== "POST") return true;
|
|
103
114
|
return !path.startsWith("/config/") && !path.startsWith("/id/");
|
|
104
115
|
}
|
|
105
|
-
|
|
116
|
+
function getMalformedUnixUrl() {
|
|
117
|
+
const raw = (process.env.CADDY_ADMIN_URL || "").trim();
|
|
118
|
+
if (!raw || !/^unix[:/]/i.test(raw)) return void 0;
|
|
119
|
+
return getUnixSocketPath() === void 0 ? raw : void 0;
|
|
120
|
+
}
|
|
121
|
+
async function caddyRequest(method, path, body, contentType, timeout, rawStringBody = false) {
|
|
122
|
+
const malformed = getMalformedUnixUrl();
|
|
123
|
+
if (malformed) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
status: 0,
|
|
127
|
+
error: `CADDY_ADMIN_URL="${malformed}" looks like a unix socket address but is not a recognized form. Use "unix:///absolute/path.sock" (URL form) or "unix//absolute/path.sock" (Caddy's own spelling). A single slash after "unix:", or a relative path, will not parse.`
|
|
128
|
+
};
|
|
129
|
+
}
|
|
106
130
|
const maxRetries = getMaxRetries();
|
|
107
131
|
let attempt = 0;
|
|
108
|
-
let res = await attemptRequest(method, path, body, contentType, timeout);
|
|
132
|
+
let res = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
|
|
109
133
|
while (isRetryableMethod(method, path) && isTransientFailure(res) && attempt < maxRetries) {
|
|
110
134
|
attempt++;
|
|
111
135
|
const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
|
|
112
136
|
const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
|
|
113
137
|
await sleep(delay);
|
|
114
|
-
res = await attemptRequest(method, path, body, contentType, timeout);
|
|
138
|
+
res = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
|
|
115
139
|
}
|
|
116
140
|
return res;
|
|
117
141
|
}
|
|
118
|
-
|
|
142
|
+
function sendViaUnixSocket(socketPath, path, method, headers, body, timeoutMs) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const req = httpRequest({ socketPath, path, method, headers, signal: AbortSignal.timeout(timeoutMs) }, (res) => {
|
|
145
|
+
const chunks = [];
|
|
146
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
147
|
+
res.on("error", reject);
|
|
148
|
+
res.on("end", () => {
|
|
149
|
+
const status = res.statusCode ?? 0;
|
|
150
|
+
const etag = res.headers.etag;
|
|
151
|
+
resolve({
|
|
152
|
+
ok: status >= 200 && status < 300,
|
|
153
|
+
status,
|
|
154
|
+
text: Buffer.concat(chunks).toString("utf8"),
|
|
155
|
+
etag: typeof etag === "string" ? etag : void 0
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
req.on("error", reject);
|
|
160
|
+
if (body !== void 0) req.write(body);
|
|
161
|
+
req.end();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async function sendViaFetch(url, method, headers, body, timeoutMs) {
|
|
165
|
+
const res = await fetch(url, {
|
|
166
|
+
method,
|
|
167
|
+
headers,
|
|
168
|
+
body,
|
|
169
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
ok: res.ok,
|
|
173
|
+
status: res.status,
|
|
174
|
+
text: await res.text(),
|
|
175
|
+
etag: res.headers.get("ETag") || void 0
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function attemptRequest(method, path, body, contentType, timeout, rawStringBody = false) {
|
|
179
|
+
const socketPath = getUnixSocketPath();
|
|
119
180
|
const url = `${getBaseUrl()}${path}`;
|
|
120
181
|
const effectiveTimeout = timeout ?? getRequestTimeout();
|
|
121
182
|
try {
|
|
122
183
|
const hasBody = body !== void 0;
|
|
123
|
-
const headers = getHeaders(hasBody ? contentType || "application/json" : void 0);
|
|
184
|
+
const headers = getHeaders(hasBody ? contentType || "application/json" : void 0, socketPath !== void 0);
|
|
124
185
|
const isConfigPath = path.startsWith("/config/") || path.startsWith("/id/");
|
|
125
186
|
const isWrite = method !== "GET";
|
|
126
187
|
if (isWrite && isConfigPath) {
|
|
127
188
|
const cachedEtag = etagCache.get(path);
|
|
128
189
|
if (cachedEtag) headers["If-Match"] = cachedEtag;
|
|
129
190
|
}
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
signal: AbortSignal.timeout(effectiveTimeout)
|
|
135
|
-
});
|
|
136
|
-
const text = await res.text();
|
|
137
|
-
const etag = res.headers.get("ETag") || void 0;
|
|
191
|
+
const serializedBody = hasBody ? rawStringBody && typeof body === "string" ? body : JSON.stringify(body) : void 0;
|
|
192
|
+
const res = socketPath ? await sendViaUnixSocket(socketPath, path, method, headers, serializedBody, effectiveTimeout) : await sendViaFetch(url, method, headers, serializedBody, effectiveTimeout);
|
|
193
|
+
const text = res.text;
|
|
194
|
+
const etag = res.etag;
|
|
138
195
|
if (method === "GET" && etag && isConfigPath) {
|
|
139
196
|
setEtag(path, etag);
|
|
140
197
|
}
|
|
@@ -158,10 +215,11 @@ async function attemptRequest(method, path, body, contentType, timeout) {
|
|
|
158
215
|
return { ok: false, status: res.status, error: `HTTP ${res.status}${hint}` };
|
|
159
216
|
}
|
|
160
217
|
if (res.status === 403 && /origin/i.test(text)) {
|
|
218
|
+
const hint = socketPath ? `Over a unix socket caddy-mcp deliberately sends no Origin header, because Caddy builds no default origin allowlist for a unix listener (sending one would fail against an empty list). Reaching here means admin.enforce_origin is enabled -- disable it, or move the admin endpoint to a TCP address.` : `Set CADDY_ADMIN_URL to the exact origin Caddy allows (default http://localhost:2019), or add this origin to the admin.origins list in Caddy's config.`;
|
|
161
219
|
return {
|
|
162
220
|
ok: false,
|
|
163
221
|
status: 403,
|
|
164
|
-
error: `${text.trim()} -- Caddy's admin API rejected this client's Origin.
|
|
222
|
+
error: `${text.trim()} -- Caddy's admin API rejected this client's Origin. ${hint}`
|
|
165
223
|
};
|
|
166
224
|
}
|
|
167
225
|
return { ok: false, status: res.status, error: text };
|
|
@@ -174,17 +232,25 @@ async function attemptRequest(method, path, body, contentType, timeout) {
|
|
|
174
232
|
}
|
|
175
233
|
} catch (err) {
|
|
176
234
|
const msg = err instanceof Error ? err.message : String(err);
|
|
235
|
+
if (socketPath && msg.includes("ENOENT")) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
status: 0,
|
|
239
|
+
error: `No socket at ${socketPath} \u2014 check the path in CADDY_ADMIN_URL and that Caddy's admin endpoint is configured to listen on it.`
|
|
240
|
+
};
|
|
241
|
+
}
|
|
177
242
|
if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
243
|
+
let target = socketPath ?? getBaseUrl();
|
|
244
|
+
if (!socketPath) {
|
|
245
|
+
try {
|
|
246
|
+
target = new URL(target).origin;
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
183
249
|
}
|
|
184
250
|
return {
|
|
185
251
|
ok: false,
|
|
186
252
|
status: 0,
|
|
187
|
-
error: `Cannot connect to Caddy admin API at ${
|
|
253
|
+
error: `Cannot connect to Caddy admin API at ${target} \u2014 is Caddy running?`
|
|
188
254
|
};
|
|
189
255
|
}
|
|
190
256
|
if (msg.includes("abort") || msg.includes("timeout")) {
|
|
@@ -242,12 +308,12 @@ function getLoadTimeout() {
|
|
|
242
308
|
return floored;
|
|
243
309
|
}
|
|
244
310
|
async function loadConfig(config, contentType) {
|
|
245
|
-
const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
|
|
311
|
+
const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout(), true);
|
|
246
312
|
if (res.ok) etagCache.clear();
|
|
247
313
|
return res;
|
|
248
314
|
}
|
|
249
315
|
function adapt(config, adapter = "caddyfile") {
|
|
250
|
-
return caddyRequest("POST", "/adapt", config, `text/${adapter}
|
|
316
|
+
return caddyRequest("POST", "/adapt", config, `text/${adapter}`, void 0, true);
|
|
251
317
|
}
|
|
252
318
|
function stop() {
|
|
253
319
|
return caddyRequest("POST", "/stop");
|
|
@@ -594,25 +660,83 @@ ${warnLines.join("\n")}` });
|
|
|
594
660
|
import { z as z3 } from "zod";
|
|
595
661
|
|
|
596
662
|
// src/snapshots.ts
|
|
663
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
664
|
+
import { join } from "path";
|
|
597
665
|
var MAX_SNAPSHOTS = 10;
|
|
598
666
|
var store = [];
|
|
667
|
+
function isSnapshotableConfig(data) {
|
|
668
|
+
return data !== null && typeof data === "object" && !Array.isArray(data);
|
|
669
|
+
}
|
|
670
|
+
var SNAPSHOT_FILE_RE = /^snapshot-(\d+)-([\w-]+)\.json$/;
|
|
671
|
+
function snapshotDir() {
|
|
672
|
+
const dir = process.env.CADDY_MCP_SNAPSHOT_DIR?.trim();
|
|
673
|
+
return dir ? dir : void 0;
|
|
674
|
+
}
|
|
675
|
+
var hydrated = false;
|
|
676
|
+
function hydrate() {
|
|
677
|
+
if (hydrated) return;
|
|
678
|
+
hydrated = true;
|
|
679
|
+
const dir = snapshotDir();
|
|
680
|
+
if (!dir || !existsSync(dir)) return;
|
|
681
|
+
try {
|
|
682
|
+
const names = readdirSync(dir).filter((n) => SNAPSHOT_FILE_RE.test(n)).sort().reverse().slice(0, MAX_SNAPSHOTS);
|
|
683
|
+
const loaded = [];
|
|
684
|
+
for (const name of names) {
|
|
685
|
+
const m = SNAPSHOT_FILE_RE.exec(name);
|
|
686
|
+
if (!m) continue;
|
|
687
|
+
try {
|
|
688
|
+
const config = JSON.parse(readFileSync(join(dir, name), "utf-8"));
|
|
689
|
+
if (!isSnapshotableConfig(config)) continue;
|
|
690
|
+
loaded.push({ config, timestamp: Number(m[1]), trigger: m[2] });
|
|
691
|
+
} catch {
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
loaded.sort((a, b) => b.timestamp - a.timestamp);
|
|
695
|
+
store.push(...loaded);
|
|
696
|
+
} catch {
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function persist(snap) {
|
|
700
|
+
const dir = snapshotDir();
|
|
701
|
+
if (!dir) return;
|
|
702
|
+
try {
|
|
703
|
+
mkdirSync(dir, { recursive: true });
|
|
704
|
+
const safeTrigger = snap.trigger.replace(/[^\w-]/g, "_");
|
|
705
|
+
writeFileSync(
|
|
706
|
+
join(dir, `snapshot-${snap.timestamp}-${safeTrigger}.json`),
|
|
707
|
+
JSON.stringify(snap.config, null, 2),
|
|
708
|
+
"utf-8"
|
|
709
|
+
);
|
|
710
|
+
const files = readdirSync(dir).filter((n) => SNAPSHOT_FILE_RE.test(n)).sort().reverse();
|
|
711
|
+
for (const stale of files.slice(MAX_SNAPSHOTS)) {
|
|
712
|
+
rmSync(join(dir, stale), { force: true });
|
|
713
|
+
}
|
|
714
|
+
} catch {
|
|
715
|
+
}
|
|
716
|
+
}
|
|
599
717
|
function saveSnapshot(config, trigger) {
|
|
600
|
-
|
|
718
|
+
hydrate();
|
|
719
|
+
let timestamp = Date.now();
|
|
720
|
+
if (store.length > 0 && timestamp <= store[0].timestamp) {
|
|
721
|
+
timestamp = store[0].timestamp + 1;
|
|
722
|
+
}
|
|
723
|
+
const snap = { config, timestamp, trigger };
|
|
724
|
+
store.unshift(snap);
|
|
601
725
|
if (store.length > MAX_SNAPSHOTS) {
|
|
602
726
|
store.length = MAX_SNAPSHOTS;
|
|
603
727
|
}
|
|
728
|
+
persist(snap);
|
|
604
729
|
}
|
|
605
730
|
function listSnapshots() {
|
|
731
|
+
hydrate();
|
|
606
732
|
return store;
|
|
607
733
|
}
|
|
608
734
|
function getSnapshot(index) {
|
|
735
|
+
hydrate();
|
|
609
736
|
return store[index];
|
|
610
737
|
}
|
|
611
738
|
|
|
612
739
|
// src/tools/config.ts
|
|
613
|
-
function isSnapshotableConfig(data) {
|
|
614
|
-
return data !== null && typeof data === "object" && !Array.isArray(data);
|
|
615
|
-
}
|
|
616
740
|
function registerConfigTools(server) {
|
|
617
741
|
server.tool(
|
|
618
742
|
"caddy_config_get",
|
|
@@ -692,7 +816,7 @@ function registerConfigTools(server) {
|
|
|
692
816
|
);
|
|
693
817
|
server.tool(
|
|
694
818
|
"caddy_revert",
|
|
695
|
-
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load
|
|
819
|
+
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load (last 10). By default they live in memory only and are LOST when this server restarts -- set CADDY_MCP_SNAPSHOT_DIR to a writable directory to persist them across restarts (they contain full Caddy configs, so pick the location deliberately). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
|
|
696
820
|
{
|
|
697
821
|
action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
|
|
698
822
|
index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
|
|
@@ -1235,6 +1359,7 @@ function buildTlsConfig(fields) {
|
|
|
1235
1359
|
const issuer = { module: "acme" };
|
|
1236
1360
|
if (fields.email) issuer.email = fields.email;
|
|
1237
1361
|
if (fields.ca) issuer.ca = fields.ca;
|
|
1362
|
+
if (fields.profile) issuer.profile = fields.profile;
|
|
1238
1363
|
return {
|
|
1239
1364
|
automation: {
|
|
1240
1365
|
policies: [{ issuers: [issuer] }]
|
|
@@ -1268,6 +1393,7 @@ function mergeIssuerFields(existing, fields) {
|
|
|
1268
1393
|
const issuer = automation.policies[0].issuers[0];
|
|
1269
1394
|
if (fields.email !== void 0) issuer.email = fields.email;
|
|
1270
1395
|
if (fields.ca !== void 0) issuer.ca = fields.ca;
|
|
1396
|
+
if (fields.profile !== void 0) issuer.profile = fields.profile;
|
|
1271
1397
|
return merged;
|
|
1272
1398
|
}
|
|
1273
1399
|
function validateIssuerShape(tls) {
|
|
@@ -1338,47 +1464,63 @@ async function safeFallback(label, patchRes, fields) {
|
|
|
1338
1464
|
};
|
|
1339
1465
|
}
|
|
1340
1466
|
const merged = mergeIssuerFields(getRes.data, fields);
|
|
1341
|
-
const
|
|
1342
|
-
if (
|
|
1343
|
-
return { kind: "tool-error", result: bothErrors(label, patchRes,
|
|
1467
|
+
const mergeRes = await configPatch("apps/tls", merged);
|
|
1468
|
+
if (mergeRes.ok) return { kind: "ok" };
|
|
1469
|
+
return { kind: "tool-error", result: bothErrors(label, patchRes, mergeRes, "PATCH apps/tls") };
|
|
1470
|
+
}
|
|
1471
|
+
async function setIssuerField(field, value, label) {
|
|
1472
|
+
const patchRes = await configPatch(`apps/tls/automation/policies/0/issuers/0/${field}`, value);
|
|
1473
|
+
const ok = { content: [{ type: "text", text: `${label} set to: ${value}` }] };
|
|
1474
|
+
if (patchRes.ok) return ok;
|
|
1475
|
+
const outcome = await safeFallback(label, patchRes, { [field]: value });
|
|
1476
|
+
return outcome.kind === "ok" ? ok : outcome.result;
|
|
1477
|
+
}
|
|
1478
|
+
function missingArgError(text) {
|
|
1479
|
+
return { isError: true, content: [{ type: "text", text: `Error: ${text}` }] };
|
|
1344
1480
|
}
|
|
1345
1481
|
function registerTlsTools(server) {
|
|
1346
1482
|
server.tool(
|
|
1347
1483
|
"caddy_tls",
|
|
1348
|
-
"Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL. Works on both fresh and existing Caddy instances.",
|
|
1484
|
+
"Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL, 'set_acme_profile' sets the ACME profile (Caddy 2.10+), 'ech_status' reads the Encrypted ClientHello config (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances. Writes target policies[0].issuers[0] only -- on a multi-policy TLS config, edit the intended policy with caddy_config_set instead.",
|
|
1349
1485
|
{
|
|
1350
|
-
action: z5.enum(["status", "set_email", "set_acme_ca"]).describe("Action to perform"),
|
|
1486
|
+
action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
|
|
1351
1487
|
email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
|
|
1352
|
-
ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)")
|
|
1488
|
+
ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)"),
|
|
1489
|
+
profile: z5.string().optional().describe(
|
|
1490
|
+
"ACME profile name (for 'set_acme_profile'). Requires Caddy 2.10+ and a CA that offers profiles; Let's Encrypt uses 'shortlived' for 6-day certificates. Valid names are defined by the CA, not by Caddy."
|
|
1491
|
+
)
|
|
1353
1492
|
},
|
|
1354
1493
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1355
|
-
async ({ action, email, ca }) => {
|
|
1494
|
+
async ({ action, email, ca, profile }) => {
|
|
1356
1495
|
if (action === "status") {
|
|
1357
1496
|
return formatResult(await configGet("apps/tls"));
|
|
1358
1497
|
}
|
|
1359
|
-
if (action === "
|
|
1360
|
-
|
|
1498
|
+
if (action === "ech_status") {
|
|
1499
|
+
const echRes = await configGet("apps/tls/ech");
|
|
1500
|
+
const absent = !echRes.ok && echRes.status === 404 || echRes.ok && (echRes.data === void 0 || echRes.data === null);
|
|
1501
|
+
if (absent) {
|
|
1361
1502
|
return {
|
|
1362
|
-
|
|
1363
|
-
|
|
1503
|
+
content: [
|
|
1504
|
+
{
|
|
1505
|
+
type: "text",
|
|
1506
|
+
text: "ECH (Encrypted ClientHello) is not configured on this instance. Requires Caddy 2.10+; enable it by applying a config with apps/tls/ech via caddy_load."
|
|
1507
|
+
}
|
|
1508
|
+
]
|
|
1364
1509
|
};
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
return
|
|
1510
|
+
}
|
|
1511
|
+
return formatResult(echRes);
|
|
1512
|
+
}
|
|
1513
|
+
if (action === "set_email") {
|
|
1514
|
+
if (!email) return missingArgError("email is required for set_email action");
|
|
1515
|
+
return setIssuerField("email", email, "ACME email");
|
|
1370
1516
|
}
|
|
1371
1517
|
if (action === "set_acme_ca") {
|
|
1372
|
-
if (!ca)
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
1379
|
-
const outcome = await safeFallback("ACME CA", patchRes, { ca });
|
|
1380
|
-
if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
1381
|
-
return outcome.result;
|
|
1518
|
+
if (!ca) return missingArgError("ca is required for set_acme_ca action");
|
|
1519
|
+
return setIssuerField("ca", ca, "ACME CA");
|
|
1520
|
+
}
|
|
1521
|
+
if (action === "set_acme_profile") {
|
|
1522
|
+
if (!profile) return missingArgError("profile is required for set_acme_profile action");
|
|
1523
|
+
return setIssuerField("profile", profile, "ACME profile");
|
|
1382
1524
|
}
|
|
1383
1525
|
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
1384
1526
|
}
|
package/dist/server.js
CHANGED
|
@@ -4,6 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
|
|
6
6
|
// src/api.ts
|
|
7
|
+
import { request as httpRequest } from "http";
|
|
7
8
|
var DEFAULT_URL = "http://localhost:2019";
|
|
8
9
|
var TIMEOUT = 1e4;
|
|
9
10
|
var RETRY_BASE_MS = 100;
|
|
@@ -43,6 +44,15 @@ function invalidateRelated(path) {
|
|
|
43
44
|
function getBaseUrl() {
|
|
44
45
|
return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
|
|
45
46
|
}
|
|
47
|
+
function getUnixSocketPath() {
|
|
48
|
+
const raw = (process.env.CADDY_ADMIN_URL || "").trim();
|
|
49
|
+
if (!raw) return void 0;
|
|
50
|
+
const urlForm = /^unix:\/\/(\/.*)$/.exec(raw);
|
|
51
|
+
if (urlForm) return urlForm[1];
|
|
52
|
+
const caddyForm = /^unix\/(\/.*)$/.exec(raw);
|
|
53
|
+
if (caddyForm) return caddyForm[1];
|
|
54
|
+
return void 0;
|
|
55
|
+
}
|
|
46
56
|
var warnedRetryClamp = false;
|
|
47
57
|
function getMaxRetries() {
|
|
48
58
|
const raw = process.env.CADDY_MAX_RETRIES;
|
|
@@ -63,11 +73,12 @@ function getAdminOrigin() {
|
|
|
63
73
|
return void 0;
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
|
-
function getHeaders(contentType) {
|
|
76
|
+
function getHeaders(contentType, overUnixSocket = false) {
|
|
67
77
|
const headers = {};
|
|
68
78
|
if (contentType) headers["Content-Type"] = contentType;
|
|
69
79
|
const token = process.env.CADDY_API_TOKEN;
|
|
70
80
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
81
|
+
if (overUnixSocket) return headers;
|
|
71
82
|
const origin = getAdminOrigin();
|
|
72
83
|
if (origin) headers.Origin = origin;
|
|
73
84
|
return headers;
|
|
@@ -100,39 +111,85 @@ function isRetryableMethod(method, path) {
|
|
|
100
111
|
if (method !== "POST") return true;
|
|
101
112
|
return !path.startsWith("/config/") && !path.startsWith("/id/");
|
|
102
113
|
}
|
|
103
|
-
|
|
114
|
+
function getMalformedUnixUrl() {
|
|
115
|
+
const raw = (process.env.CADDY_ADMIN_URL || "").trim();
|
|
116
|
+
if (!raw || !/^unix[:/]/i.test(raw)) return void 0;
|
|
117
|
+
return getUnixSocketPath() === void 0 ? raw : void 0;
|
|
118
|
+
}
|
|
119
|
+
async function caddyRequest(method, path, body, contentType, timeout, rawStringBody = false) {
|
|
120
|
+
const malformed = getMalformedUnixUrl();
|
|
121
|
+
if (malformed) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
status: 0,
|
|
125
|
+
error: `CADDY_ADMIN_URL="${malformed}" looks like a unix socket address but is not a recognized form. Use "unix:///absolute/path.sock" (URL form) or "unix//absolute/path.sock" (Caddy's own spelling). A single slash after "unix:", or a relative path, will not parse.`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
104
128
|
const maxRetries = getMaxRetries();
|
|
105
129
|
let attempt = 0;
|
|
106
|
-
let res = await attemptRequest(method, path, body, contentType, timeout);
|
|
130
|
+
let res = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
|
|
107
131
|
while (isRetryableMethod(method, path) && isTransientFailure(res) && attempt < maxRetries) {
|
|
108
132
|
attempt++;
|
|
109
133
|
const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS);
|
|
110
134
|
const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
|
|
111
135
|
await sleep(delay);
|
|
112
|
-
res = await attemptRequest(method, path, body, contentType, timeout);
|
|
136
|
+
res = await attemptRequest(method, path, body, contentType, timeout, rawStringBody);
|
|
113
137
|
}
|
|
114
138
|
return res;
|
|
115
139
|
}
|
|
116
|
-
|
|
140
|
+
function sendViaUnixSocket(socketPath, path, method, headers, body, timeoutMs) {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const req = httpRequest({ socketPath, path, method, headers, signal: AbortSignal.timeout(timeoutMs) }, (res) => {
|
|
143
|
+
const chunks = [];
|
|
144
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
145
|
+
res.on("error", reject);
|
|
146
|
+
res.on("end", () => {
|
|
147
|
+
const status = res.statusCode ?? 0;
|
|
148
|
+
const etag = res.headers.etag;
|
|
149
|
+
resolve({
|
|
150
|
+
ok: status >= 200 && status < 300,
|
|
151
|
+
status,
|
|
152
|
+
text: Buffer.concat(chunks).toString("utf8"),
|
|
153
|
+
etag: typeof etag === "string" ? etag : void 0
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
req.on("error", reject);
|
|
158
|
+
if (body !== void 0) req.write(body);
|
|
159
|
+
req.end();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async function sendViaFetch(url, method, headers, body, timeoutMs) {
|
|
163
|
+
const res = await fetch(url, {
|
|
164
|
+
method,
|
|
165
|
+
headers,
|
|
166
|
+
body,
|
|
167
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
ok: res.ok,
|
|
171
|
+
status: res.status,
|
|
172
|
+
text: await res.text(),
|
|
173
|
+
etag: res.headers.get("ETag") || void 0
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
async function attemptRequest(method, path, body, contentType, timeout, rawStringBody = false) {
|
|
177
|
+
const socketPath = getUnixSocketPath();
|
|
117
178
|
const url = `${getBaseUrl()}${path}`;
|
|
118
179
|
const effectiveTimeout = timeout ?? getRequestTimeout();
|
|
119
180
|
try {
|
|
120
181
|
const hasBody = body !== void 0;
|
|
121
|
-
const headers = getHeaders(hasBody ? contentType || "application/json" : void 0);
|
|
182
|
+
const headers = getHeaders(hasBody ? contentType || "application/json" : void 0, socketPath !== void 0);
|
|
122
183
|
const isConfigPath = path.startsWith("/config/") || path.startsWith("/id/");
|
|
123
184
|
const isWrite = method !== "GET";
|
|
124
185
|
if (isWrite && isConfigPath) {
|
|
125
186
|
const cachedEtag = etagCache.get(path);
|
|
126
187
|
if (cachedEtag) headers["If-Match"] = cachedEtag;
|
|
127
188
|
}
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
signal: AbortSignal.timeout(effectiveTimeout)
|
|
133
|
-
});
|
|
134
|
-
const text = await res.text();
|
|
135
|
-
const etag = res.headers.get("ETag") || void 0;
|
|
189
|
+
const serializedBody = hasBody ? rawStringBody && typeof body === "string" ? body : JSON.stringify(body) : void 0;
|
|
190
|
+
const res = socketPath ? await sendViaUnixSocket(socketPath, path, method, headers, serializedBody, effectiveTimeout) : await sendViaFetch(url, method, headers, serializedBody, effectiveTimeout);
|
|
191
|
+
const text = res.text;
|
|
192
|
+
const etag = res.etag;
|
|
136
193
|
if (method === "GET" && etag && isConfigPath) {
|
|
137
194
|
setEtag(path, etag);
|
|
138
195
|
}
|
|
@@ -156,10 +213,11 @@ async function attemptRequest(method, path, body, contentType, timeout) {
|
|
|
156
213
|
return { ok: false, status: res.status, error: `HTTP ${res.status}${hint}` };
|
|
157
214
|
}
|
|
158
215
|
if (res.status === 403 && /origin/i.test(text)) {
|
|
216
|
+
const hint = socketPath ? `Over a unix socket caddy-mcp deliberately sends no Origin header, because Caddy builds no default origin allowlist for a unix listener (sending one would fail against an empty list). Reaching here means admin.enforce_origin is enabled -- disable it, or move the admin endpoint to a TCP address.` : `Set CADDY_ADMIN_URL to the exact origin Caddy allows (default http://localhost:2019), or add this origin to the admin.origins list in Caddy's config.`;
|
|
159
217
|
return {
|
|
160
218
|
ok: false,
|
|
161
219
|
status: 403,
|
|
162
|
-
error: `${text.trim()} -- Caddy's admin API rejected this client's Origin.
|
|
220
|
+
error: `${text.trim()} -- Caddy's admin API rejected this client's Origin. ${hint}`
|
|
163
221
|
};
|
|
164
222
|
}
|
|
165
223
|
return { ok: false, status: res.status, error: text };
|
|
@@ -172,17 +230,25 @@ async function attemptRequest(method, path, body, contentType, timeout) {
|
|
|
172
230
|
}
|
|
173
231
|
} catch (err) {
|
|
174
232
|
const msg = err instanceof Error ? err.message : String(err);
|
|
233
|
+
if (socketPath && msg.includes("ENOENT")) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
status: 0,
|
|
237
|
+
error: `No socket at ${socketPath} \u2014 check the path in CADDY_ADMIN_URL and that Caddy's admin endpoint is configured to listen on it.`
|
|
238
|
+
};
|
|
239
|
+
}
|
|
175
240
|
if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
241
|
+
let target = socketPath ?? getBaseUrl();
|
|
242
|
+
if (!socketPath) {
|
|
243
|
+
try {
|
|
244
|
+
target = new URL(target).origin;
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
181
247
|
}
|
|
182
248
|
return {
|
|
183
249
|
ok: false,
|
|
184
250
|
status: 0,
|
|
185
|
-
error: `Cannot connect to Caddy admin API at ${
|
|
251
|
+
error: `Cannot connect to Caddy admin API at ${target} \u2014 is Caddy running?`
|
|
186
252
|
};
|
|
187
253
|
}
|
|
188
254
|
if (msg.includes("abort") || msg.includes("timeout")) {
|
|
@@ -240,12 +306,12 @@ function getLoadTimeout() {
|
|
|
240
306
|
return floored;
|
|
241
307
|
}
|
|
242
308
|
async function loadConfig(config, contentType) {
|
|
243
|
-
const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout());
|
|
309
|
+
const res = await caddyRequest("POST", "/load", config, contentType, getLoadTimeout(), true);
|
|
244
310
|
if (res.ok) etagCache.clear();
|
|
245
311
|
return res;
|
|
246
312
|
}
|
|
247
313
|
function adapt(config, adapter = "caddyfile") {
|
|
248
|
-
return caddyRequest("POST", "/adapt", config, `text/${adapter}
|
|
314
|
+
return caddyRequest("POST", "/adapt", config, `text/${adapter}`, void 0, true);
|
|
249
315
|
}
|
|
250
316
|
function stop() {
|
|
251
317
|
return caddyRequest("POST", "/stop");
|
|
@@ -592,25 +658,83 @@ ${warnLines.join("\n")}` });
|
|
|
592
658
|
import { z as z3 } from "zod";
|
|
593
659
|
|
|
594
660
|
// src/snapshots.ts
|
|
661
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
662
|
+
import { join } from "path";
|
|
595
663
|
var MAX_SNAPSHOTS = 10;
|
|
596
664
|
var store = [];
|
|
665
|
+
function isSnapshotableConfig(data) {
|
|
666
|
+
return data !== null && typeof data === "object" && !Array.isArray(data);
|
|
667
|
+
}
|
|
668
|
+
var SNAPSHOT_FILE_RE = /^snapshot-(\d+)-([\w-]+)\.json$/;
|
|
669
|
+
function snapshotDir() {
|
|
670
|
+
const dir = process.env.CADDY_MCP_SNAPSHOT_DIR?.trim();
|
|
671
|
+
return dir ? dir : void 0;
|
|
672
|
+
}
|
|
673
|
+
var hydrated = false;
|
|
674
|
+
function hydrate() {
|
|
675
|
+
if (hydrated) return;
|
|
676
|
+
hydrated = true;
|
|
677
|
+
const dir = snapshotDir();
|
|
678
|
+
if (!dir || !existsSync(dir)) return;
|
|
679
|
+
try {
|
|
680
|
+
const names = readdirSync(dir).filter((n) => SNAPSHOT_FILE_RE.test(n)).sort().reverse().slice(0, MAX_SNAPSHOTS);
|
|
681
|
+
const loaded = [];
|
|
682
|
+
for (const name of names) {
|
|
683
|
+
const m = SNAPSHOT_FILE_RE.exec(name);
|
|
684
|
+
if (!m) continue;
|
|
685
|
+
try {
|
|
686
|
+
const config = JSON.parse(readFileSync(join(dir, name), "utf-8"));
|
|
687
|
+
if (!isSnapshotableConfig(config)) continue;
|
|
688
|
+
loaded.push({ config, timestamp: Number(m[1]), trigger: m[2] });
|
|
689
|
+
} catch {
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
loaded.sort((a, b) => b.timestamp - a.timestamp);
|
|
693
|
+
store.push(...loaded);
|
|
694
|
+
} catch {
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
function persist(snap) {
|
|
698
|
+
const dir = snapshotDir();
|
|
699
|
+
if (!dir) return;
|
|
700
|
+
try {
|
|
701
|
+
mkdirSync(dir, { recursive: true });
|
|
702
|
+
const safeTrigger = snap.trigger.replace(/[^\w-]/g, "_");
|
|
703
|
+
writeFileSync(
|
|
704
|
+
join(dir, `snapshot-${snap.timestamp}-${safeTrigger}.json`),
|
|
705
|
+
JSON.stringify(snap.config, null, 2),
|
|
706
|
+
"utf-8"
|
|
707
|
+
);
|
|
708
|
+
const files = readdirSync(dir).filter((n) => SNAPSHOT_FILE_RE.test(n)).sort().reverse();
|
|
709
|
+
for (const stale of files.slice(MAX_SNAPSHOTS)) {
|
|
710
|
+
rmSync(join(dir, stale), { force: true });
|
|
711
|
+
}
|
|
712
|
+
} catch {
|
|
713
|
+
}
|
|
714
|
+
}
|
|
597
715
|
function saveSnapshot(config, trigger) {
|
|
598
|
-
|
|
716
|
+
hydrate();
|
|
717
|
+
let timestamp = Date.now();
|
|
718
|
+
if (store.length > 0 && timestamp <= store[0].timestamp) {
|
|
719
|
+
timestamp = store[0].timestamp + 1;
|
|
720
|
+
}
|
|
721
|
+
const snap = { config, timestamp, trigger };
|
|
722
|
+
store.unshift(snap);
|
|
599
723
|
if (store.length > MAX_SNAPSHOTS) {
|
|
600
724
|
store.length = MAX_SNAPSHOTS;
|
|
601
725
|
}
|
|
726
|
+
persist(snap);
|
|
602
727
|
}
|
|
603
728
|
function listSnapshots() {
|
|
729
|
+
hydrate();
|
|
604
730
|
return store;
|
|
605
731
|
}
|
|
606
732
|
function getSnapshot(index) {
|
|
733
|
+
hydrate();
|
|
607
734
|
return store[index];
|
|
608
735
|
}
|
|
609
736
|
|
|
610
737
|
// src/tools/config.ts
|
|
611
|
-
function isSnapshotableConfig(data) {
|
|
612
|
-
return data !== null && typeof data === "object" && !Array.isArray(data);
|
|
613
|
-
}
|
|
614
738
|
function registerConfigTools(server) {
|
|
615
739
|
server.tool(
|
|
616
740
|
"caddy_config_get",
|
|
@@ -690,7 +814,7 @@ function registerConfigTools(server) {
|
|
|
690
814
|
);
|
|
691
815
|
server.tool(
|
|
692
816
|
"caddy_revert",
|
|
693
|
-
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load
|
|
817
|
+
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load (last 10). By default they live in memory only and are LOST when this server restarts -- set CADDY_MCP_SNAPSHOT_DIR to a writable directory to persist them across restarts (they contain full Caddy configs, so pick the location deliberately). Actions: 'list' shows snapshots with timestamps, 'save' manually captures the current config, 'apply' restores a snapshot (requires confirm=true).",
|
|
694
818
|
{
|
|
695
819
|
action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
|
|
696
820
|
index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
|
|
@@ -1233,6 +1357,7 @@ function buildTlsConfig(fields) {
|
|
|
1233
1357
|
const issuer = { module: "acme" };
|
|
1234
1358
|
if (fields.email) issuer.email = fields.email;
|
|
1235
1359
|
if (fields.ca) issuer.ca = fields.ca;
|
|
1360
|
+
if (fields.profile) issuer.profile = fields.profile;
|
|
1236
1361
|
return {
|
|
1237
1362
|
automation: {
|
|
1238
1363
|
policies: [{ issuers: [issuer] }]
|
|
@@ -1266,6 +1391,7 @@ function mergeIssuerFields(existing, fields) {
|
|
|
1266
1391
|
const issuer = automation.policies[0].issuers[0];
|
|
1267
1392
|
if (fields.email !== void 0) issuer.email = fields.email;
|
|
1268
1393
|
if (fields.ca !== void 0) issuer.ca = fields.ca;
|
|
1394
|
+
if (fields.profile !== void 0) issuer.profile = fields.profile;
|
|
1269
1395
|
return merged;
|
|
1270
1396
|
}
|
|
1271
1397
|
function validateIssuerShape(tls) {
|
|
@@ -1336,47 +1462,63 @@ async function safeFallback(label, patchRes, fields) {
|
|
|
1336
1462
|
};
|
|
1337
1463
|
}
|
|
1338
1464
|
const merged = mergeIssuerFields(getRes.data, fields);
|
|
1339
|
-
const
|
|
1340
|
-
if (
|
|
1341
|
-
return { kind: "tool-error", result: bothErrors(label, patchRes,
|
|
1465
|
+
const mergeRes = await configPatch("apps/tls", merged);
|
|
1466
|
+
if (mergeRes.ok) return { kind: "ok" };
|
|
1467
|
+
return { kind: "tool-error", result: bothErrors(label, patchRes, mergeRes, "PATCH apps/tls") };
|
|
1468
|
+
}
|
|
1469
|
+
async function setIssuerField(field, value, label) {
|
|
1470
|
+
const patchRes = await configPatch(`apps/tls/automation/policies/0/issuers/0/${field}`, value);
|
|
1471
|
+
const ok = { content: [{ type: "text", text: `${label} set to: ${value}` }] };
|
|
1472
|
+
if (patchRes.ok) return ok;
|
|
1473
|
+
const outcome = await safeFallback(label, patchRes, { [field]: value });
|
|
1474
|
+
return outcome.kind === "ok" ? ok : outcome.result;
|
|
1475
|
+
}
|
|
1476
|
+
function missingArgError(text) {
|
|
1477
|
+
return { isError: true, content: [{ type: "text", text: `Error: ${text}` }] };
|
|
1342
1478
|
}
|
|
1343
1479
|
function registerTlsTools(server) {
|
|
1344
1480
|
server.tool(
|
|
1345
1481
|
"caddy_tls",
|
|
1346
|
-
"Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL. Works on both fresh and existing Caddy instances.",
|
|
1482
|
+
"Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL, 'set_acme_profile' sets the ACME profile (Caddy 2.10+), 'ech_status' reads the Encrypted ClientHello config (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances. Writes target policies[0].issuers[0] only -- on a multi-policy TLS config, edit the intended policy with caddy_config_set instead.",
|
|
1347
1483
|
{
|
|
1348
|
-
action: z5.enum(["status", "set_email", "set_acme_ca"]).describe("Action to perform"),
|
|
1484
|
+
action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
|
|
1349
1485
|
email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
|
|
1350
|
-
ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)")
|
|
1486
|
+
ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)"),
|
|
1487
|
+
profile: z5.string().optional().describe(
|
|
1488
|
+
"ACME profile name (for 'set_acme_profile'). Requires Caddy 2.10+ and a CA that offers profiles; Let's Encrypt uses 'shortlived' for 6-day certificates. Valid names are defined by the CA, not by Caddy."
|
|
1489
|
+
)
|
|
1351
1490
|
},
|
|
1352
1491
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1353
|
-
async ({ action, email, ca }) => {
|
|
1492
|
+
async ({ action, email, ca, profile }) => {
|
|
1354
1493
|
if (action === "status") {
|
|
1355
1494
|
return formatResult(await configGet("apps/tls"));
|
|
1356
1495
|
}
|
|
1357
|
-
if (action === "
|
|
1358
|
-
|
|
1496
|
+
if (action === "ech_status") {
|
|
1497
|
+
const echRes = await configGet("apps/tls/ech");
|
|
1498
|
+
const absent = !echRes.ok && echRes.status === 404 || echRes.ok && (echRes.data === void 0 || echRes.data === null);
|
|
1499
|
+
if (absent) {
|
|
1359
1500
|
return {
|
|
1360
|
-
|
|
1361
|
-
|
|
1501
|
+
content: [
|
|
1502
|
+
{
|
|
1503
|
+
type: "text",
|
|
1504
|
+
text: "ECH (Encrypted ClientHello) is not configured on this instance. Requires Caddy 2.10+; enable it by applying a config with apps/tls/ech via caddy_load."
|
|
1505
|
+
}
|
|
1506
|
+
]
|
|
1362
1507
|
};
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
return
|
|
1508
|
+
}
|
|
1509
|
+
return formatResult(echRes);
|
|
1510
|
+
}
|
|
1511
|
+
if (action === "set_email") {
|
|
1512
|
+
if (!email) return missingArgError("email is required for set_email action");
|
|
1513
|
+
return setIssuerField("email", email, "ACME email");
|
|
1368
1514
|
}
|
|
1369
1515
|
if (action === "set_acme_ca") {
|
|
1370
|
-
if (!ca)
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
1377
|
-
const outcome = await safeFallback("ACME CA", patchRes, { ca });
|
|
1378
|
-
if (outcome.kind === "ok") return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
1379
|
-
return outcome.result;
|
|
1516
|
+
if (!ca) return missingArgError("ca is required for set_acme_ca action");
|
|
1517
|
+
return setIssuerField("ca", ca, "ACME CA");
|
|
1518
|
+
}
|
|
1519
|
+
if (action === "set_acme_profile") {
|
|
1520
|
+
if (!profile) return missingArgError("profile is required for set_acme_profile action");
|
|
1521
|
+
return setIssuerField("profile", profile, "ACME profile");
|
|
1380
1522
|
}
|
|
1381
1523
|
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
1382
1524
|
}
|
package/dist/snapshots.d.ts
CHANGED
|
@@ -3,6 +3,16 @@ export interface Snapshot {
|
|
|
3
3
|
timestamp: number;
|
|
4
4
|
trigger: string;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* A snapshot must be re-applyable via /load, which requires a JSON object root.
|
|
8
|
+
* Strings, arrays, null, and primitives are not valid Caddy configs -- refuse to
|
|
9
|
+
* capture them so a later `caddy_revert apply` can't replay garbage.
|
|
10
|
+
*
|
|
11
|
+
* Exported because both entry points into the ring have to agree: the in-memory
|
|
12
|
+
* save path in tools/config.ts, and the rehydration path below. A file on disk
|
|
13
|
+
* that merely parses as JSON is not automatically a config.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isSnapshotableConfig(data: unknown): data is Record<string, unknown>;
|
|
6
16
|
export declare function saveSnapshot(config: unknown, trigger: string): void;
|
|
7
17
|
export declare function listSnapshots(): readonly Snapshot[];
|
|
8
18
|
export declare function getSnapshot(index: number): Snapshot | undefined;
|