@yawlabs/caddy-mcp 2.5.2 → 2.5.4
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 +59 -27
- package/dist/api.d.ts +20 -0
- package/dist/format.d.ts +10 -1
- package/dist/index.js +408 -89
- package/dist/server.js +408 -89
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -12,6 +12,7 @@ var RETRY_MAX_DELAY_MS = 2e3;
|
|
|
12
12
|
var RETRY_MAX_JITTER_MS = 50;
|
|
13
13
|
var RETRY_HARD_CAP = 5;
|
|
14
14
|
var ADMIN_RESTART_SETTLE_MS = 250;
|
|
15
|
+
var LOAD_TIMEOUT = 55e3;
|
|
15
16
|
var etagCache = /* @__PURE__ */ new Map();
|
|
16
17
|
var MAX_ETAG_CACHE = 256;
|
|
17
18
|
function setEtag(path, etag) {
|
|
@@ -21,6 +22,54 @@ function setEtag(path, etag) {
|
|
|
21
22
|
}
|
|
22
23
|
etagCache.set(path, etag);
|
|
23
24
|
}
|
|
25
|
+
var GO_WHITESPACE = /* @__PURE__ */ new Set([
|
|
26
|
+
9,
|
|
27
|
+
10,
|
|
28
|
+
11,
|
|
29
|
+
12,
|
|
30
|
+
13,
|
|
31
|
+
32,
|
|
32
|
+
133,
|
|
33
|
+
160,
|
|
34
|
+
5760,
|
|
35
|
+
8192,
|
|
36
|
+
8193,
|
|
37
|
+
8194,
|
|
38
|
+
8195,
|
|
39
|
+
8196,
|
|
40
|
+
8197,
|
|
41
|
+
8198,
|
|
42
|
+
8199,
|
|
43
|
+
8200,
|
|
44
|
+
8201,
|
|
45
|
+
8202,
|
|
46
|
+
8232,
|
|
47
|
+
8233,
|
|
48
|
+
8239,
|
|
49
|
+
8287,
|
|
50
|
+
12288
|
|
51
|
+
]);
|
|
52
|
+
function goFields(text) {
|
|
53
|
+
const fields = [];
|
|
54
|
+
let current = "";
|
|
55
|
+
for (const ch of text) {
|
|
56
|
+
if (GO_WHITESPACE.has(ch.codePointAt(0))) {
|
|
57
|
+
if (current) fields.push(current);
|
|
58
|
+
current = "";
|
|
59
|
+
} else {
|
|
60
|
+
current += ch;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (current) fields.push(current);
|
|
64
|
+
return fields;
|
|
65
|
+
}
|
|
66
|
+
function isEchoableEtag(etag) {
|
|
67
|
+
const decoded = Buffer.from(etag, "latin1").toString("utf8");
|
|
68
|
+
if (decoded.length < 2 || !decoded.startsWith('"') || !decoded.endsWith('"')) return false;
|
|
69
|
+
const inner = decoded.slice(1, -1);
|
|
70
|
+
const fields = goFields(inner);
|
|
71
|
+
return fields.length === 2 && fields.join(" ") === inner;
|
|
72
|
+
}
|
|
24
73
|
function isAncestorOf(ancestor, descendant) {
|
|
25
74
|
const base = ancestor.endsWith("/") ? ancestor.slice(0, -1) : ancestor;
|
|
26
75
|
return descendant.startsWith(`${base}/`);
|
|
@@ -106,25 +155,36 @@ function sleep(ms) {
|
|
|
106
155
|
function isTransientFailure(res) {
|
|
107
156
|
if (res.ok) return false;
|
|
108
157
|
if (res.status === 0) return true;
|
|
109
|
-
|
|
110
|
-
return false;
|
|
158
|
+
return res.status === 502 || res.status === 503 || res.status === 504;
|
|
111
159
|
}
|
|
112
160
|
function isMissingConfigPath(res) {
|
|
113
161
|
if (res.ok) return false;
|
|
114
162
|
return (res.error ?? "").toLowerCase().includes("invalid traversal path");
|
|
115
163
|
}
|
|
116
|
-
var ARRAY_INDEX_TAIL_RE = /\/\d
|
|
164
|
+
var ARRAY_INDEX_TAIL_RE = /\/\d+(\/+\.\.\.)?\/*$/;
|
|
165
|
+
var BARE_ID_RE = /^\/id\/[^/]+(\/+\.\.\.)?\/*$/;
|
|
166
|
+
function isConfigChange(method, path) {
|
|
167
|
+
if (method === "GET") return false;
|
|
168
|
+
return path === "/load" || path.startsWith("/config/") || path.startsWith("/id/");
|
|
169
|
+
}
|
|
117
170
|
function isRetryableMethod(method, path) {
|
|
118
|
-
if (method === "PUT"
|
|
171
|
+
if (method === "PUT" && BARE_ID_RE.test(path)) return false;
|
|
172
|
+
if (method === "PUT" || method === "DELETE") return !ARRAY_INDEX_TAIL_RE.test(path);
|
|
119
173
|
if (method !== "POST") return true;
|
|
120
174
|
return !path.startsWith("/config/") && !path.startsWith("/id/");
|
|
121
175
|
}
|
|
176
|
+
function shouldRetry(method, path, attempt) {
|
|
177
|
+
if (!isTransientFailure(attempt.res)) return false;
|
|
178
|
+
if (attempt.refused) return true;
|
|
179
|
+
if (attempt.timedOut && isConfigChange(method, path)) return false;
|
|
180
|
+
return isRetryableMethod(method, path);
|
|
181
|
+
}
|
|
122
182
|
function getMalformedUnixUrl() {
|
|
123
183
|
const raw = (process.env.CADDY_ADMIN_URL || "").trim();
|
|
124
184
|
if (!raw || !/^unix[:/]/i.test(raw)) return void 0;
|
|
125
185
|
return getUnixSocketPath() === void 0 ? raw : void 0;
|
|
126
186
|
}
|
|
127
|
-
async function caddyRequest(method, path, body, contentType,
|
|
187
|
+
async function caddyRequest(method, path, body, contentType, rawStringBody = false) {
|
|
128
188
|
const malformed = getMalformedUnixUrl();
|
|
129
189
|
if (malformed) {
|
|
130
190
|
return {
|
|
@@ -134,16 +194,16 @@ async function caddyRequest(method, path, body, contentType, timeout, rawStringB
|
|
|
134
194
|
};
|
|
135
195
|
}
|
|
136
196
|
const maxRetries = getMaxRetries();
|
|
137
|
-
let
|
|
138
|
-
let
|
|
139
|
-
while (
|
|
140
|
-
|
|
141
|
-
const backoff = Math.min(RETRY_BASE_MS * 2 ** (
|
|
197
|
+
let retries = 0;
|
|
198
|
+
let attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
|
|
199
|
+
while (retries < maxRetries && shouldRetry(method, path, attempt)) {
|
|
200
|
+
retries++;
|
|
201
|
+
const backoff = Math.min(RETRY_BASE_MS * 2 ** (retries - 1), RETRY_MAX_DELAY_MS);
|
|
142
202
|
const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
|
|
143
203
|
await sleep(delay);
|
|
144
|
-
|
|
204
|
+
attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
|
|
145
205
|
}
|
|
146
|
-
return res;
|
|
206
|
+
return attempt.res;
|
|
147
207
|
}
|
|
148
208
|
function isConnectionRefused(err) {
|
|
149
209
|
let current = err;
|
|
@@ -157,27 +217,44 @@ function isConnectionRefused(err) {
|
|
|
157
217
|
}
|
|
158
218
|
return false;
|
|
159
219
|
}
|
|
220
|
+
function isTimeoutError(err) {
|
|
221
|
+
let current = err;
|
|
222
|
+
for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth++) {
|
|
223
|
+
const e = current;
|
|
224
|
+
if (e.name === "TimeoutError" || e.name === "AbortError") return true;
|
|
225
|
+
current = e.cause;
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
160
229
|
function sendViaUnixSocket(socketPath, path, method, headers, body, timeoutMs) {
|
|
161
230
|
return new Promise((resolve, reject) => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
231
|
+
let deadlineHit = false;
|
|
232
|
+
const deadline = Object.assign(new Error(`timed out after ${timeoutMs}ms`), { name: "TimeoutError" });
|
|
233
|
+
function fail(err) {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
reject(deadlineHit ? deadline : err);
|
|
236
|
+
}
|
|
237
|
+
const req = httpRequest({ socketPath, path, method, headers, agent: false }, (res) => {
|
|
238
|
+
const chunks = [];
|
|
239
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
240
|
+
res.on("error", fail);
|
|
241
|
+
res.on("end", () => {
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
const status = res.statusCode ?? 0;
|
|
244
|
+
const etag = res.headers.etag;
|
|
245
|
+
resolve({
|
|
246
|
+
ok: status >= 200 && status < 300,
|
|
247
|
+
status,
|
|
248
|
+
text: Buffer.concat(chunks).toString("utf8"),
|
|
249
|
+
etag: typeof etag === "string" ? etag : void 0
|
|
177
250
|
});
|
|
178
|
-
}
|
|
179
|
-
);
|
|
180
|
-
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
const timer = setTimeout(() => {
|
|
254
|
+
deadlineHit = true;
|
|
255
|
+
req.destroy(deadline);
|
|
256
|
+
}, timeoutMs);
|
|
257
|
+
req.on("error", fail);
|
|
181
258
|
if (body !== void 0) req.write(body);
|
|
182
259
|
req.end();
|
|
183
260
|
});
|
|
@@ -236,19 +313,65 @@ function settleAdminRestart(origin) {
|
|
|
236
313
|
socketWaiters.add(check);
|
|
237
314
|
});
|
|
238
315
|
}
|
|
239
|
-
function
|
|
240
|
-
|
|
241
|
-
|
|
316
|
+
function endOfFirstJsonValue(text) {
|
|
317
|
+
let depth = 0;
|
|
318
|
+
let inString = false;
|
|
319
|
+
let escaped = false;
|
|
320
|
+
for (let i = 0; i < text.length; i++) {
|
|
321
|
+
const ch = text[i];
|
|
322
|
+
if (inString) {
|
|
323
|
+
if (escaped) escaped = false;
|
|
324
|
+
else if (ch === "\\") escaped = true;
|
|
325
|
+
else if (ch === '"') inString = false;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (ch === '"') inString = true;
|
|
329
|
+
else if (ch === "[" || ch === "{") depth++;
|
|
330
|
+
else if (ch === "]" || ch === "}") {
|
|
331
|
+
depth--;
|
|
332
|
+
if (depth <= 0) return depth === 0 ? i + 1 : -1;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return -1;
|
|
336
|
+
}
|
|
337
|
+
function parseJsonOrUndefined(text) {
|
|
338
|
+
try {
|
|
339
|
+
return JSON.parse(text);
|
|
340
|
+
} catch {
|
|
341
|
+
return void 0;
|
|
342
|
+
}
|
|
242
343
|
}
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
344
|
+
function readLoadBody(text) {
|
|
345
|
+
const body = text.trim();
|
|
346
|
+
if (body.startsWith("[")) {
|
|
347
|
+
const end = endOfFirstJsonValue(body);
|
|
348
|
+
if (end === -1) return void 0;
|
|
349
|
+
const warnings = parseJsonOrUndefined(body.slice(0, end));
|
|
350
|
+
if (!Array.isArray(warnings)) return void 0;
|
|
351
|
+
const tail = body.slice(end).trim();
|
|
352
|
+
if (!tail) return { warnings };
|
|
353
|
+
const trailing = parseJsonOrUndefined(tail);
|
|
354
|
+
if (trailing === null || typeof trailing !== "object" || Array.isArray(trailing)) return void 0;
|
|
355
|
+
if (typeof trailing.error !== "string") return void 0;
|
|
356
|
+
return { warnings, errorText: tail };
|
|
357
|
+
}
|
|
358
|
+
if (body.startsWith("{")) {
|
|
359
|
+
const parsed = parseJsonOrUndefined(body);
|
|
360
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
|
|
361
|
+
const warnings = parsed.warnings;
|
|
362
|
+
if (Array.isArray(warnings) && Object.keys(parsed).length === 1) return { warnings };
|
|
363
|
+
}
|
|
364
|
+
return void 0;
|
|
247
365
|
}
|
|
248
|
-
async function
|
|
366
|
+
async function attemptRequest(method, path, body, contentType, rawStringBody = false) {
|
|
367
|
+
const transport = { refused: false, timedOut: false };
|
|
368
|
+
const res = await sendOnce(transport, method, path, body, contentType, rawStringBody);
|
|
369
|
+
return { res, refused: transport.refused, timedOut: transport.timedOut };
|
|
370
|
+
}
|
|
371
|
+
async function sendOnce(transport, method, path, body, contentType, rawStringBody = false) {
|
|
249
372
|
const socketPath = getUnixSocketPath();
|
|
250
373
|
const url = `${getBaseUrl()}${path}`;
|
|
251
|
-
const effectiveTimeout =
|
|
374
|
+
const effectiveTimeout = getTimeoutFor(method, path);
|
|
252
375
|
try {
|
|
253
376
|
const hasBody = body !== void 0;
|
|
254
377
|
const headers = getHeaders(hasBody ? contentType || "application/json" : void 0, socketPath !== void 0);
|
|
@@ -260,11 +383,21 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
|
|
|
260
383
|
}
|
|
261
384
|
const serializedBody = hasBody ? rawStringBody && typeof body === "string" ? body : JSON.stringify(body) : void 0;
|
|
262
385
|
const res = socketPath ? await sendViaUnixSocket(socketPath, path, method, headers, serializedBody, effectiveTimeout) : await sendViaFetch(url, method, headers, serializedBody, effectiveTimeout);
|
|
386
|
+
const loadBody = path === "/load" && res.ok ? readLoadBody(res.text) : void 0;
|
|
387
|
+
if (loadBody?.errorText !== void 0) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
status: res.status,
|
|
391
|
+
error: `${loadBody.errorText} -- Caddy answered HTTP ${res.status}, but the response body carries this load error after its config-adapter warnings, so caddy-mcp reports the load as failed. Caddy writes the warnings before it runs the load, which fixes the status at 200 whatever the load then does (caddyserver/caddy#7246); without warnings it answers the same failure with 400. Re-read the config to confirm what is running.`,
|
|
392
|
+
warnings: loadBody.warnings
|
|
393
|
+
};
|
|
394
|
+
}
|
|
263
395
|
if (!socketPath && res.ok && isConfigChange(method, path)) await settleAdminRestart(getAdminOrigin());
|
|
264
396
|
const text = res.text;
|
|
265
397
|
const etag = res.etag;
|
|
266
398
|
if (method === "GET" && etag && isConfigPath) {
|
|
267
|
-
setEtag(path, etag);
|
|
399
|
+
if (isEchoableEtag(etag)) setEtag(path, etag);
|
|
400
|
+
else etagCache.delete(path);
|
|
268
401
|
}
|
|
269
402
|
if (isWrite && res.ok && isConfigPath) {
|
|
270
403
|
invalidateRelated(path);
|
|
@@ -296,6 +429,7 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
|
|
|
296
429
|
return { ok: false, status: res.status, error: text };
|
|
297
430
|
}
|
|
298
431
|
if (!text) return { ok: true, status: res.status, etag };
|
|
432
|
+
if (loadBody) return { ok: true, status: res.status, warnings: loadBody.warnings, etag };
|
|
299
433
|
try {
|
|
300
434
|
return { ok: true, status: res.status, data: JSON.parse(text), etag };
|
|
301
435
|
} catch {
|
|
@@ -325,8 +459,16 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
|
|
|
325
459
|
error: `Cannot connect to Caddy admin API at ${target} \u2014 is Caddy running?`
|
|
326
460
|
};
|
|
327
461
|
}
|
|
328
|
-
if (msg.includes("abort") || msg.includes("timeout")) {
|
|
329
|
-
|
|
462
|
+
if (isTimeoutError(err) || msg.includes("abort") || msg.includes("timeout") || msg.includes("timed out")) {
|
|
463
|
+
transport.timedOut = true;
|
|
464
|
+
const timedOutMsg = `Request timed out after ${effectiveTimeout}ms`;
|
|
465
|
+
if (!isConfigChange(method, path)) return { ok: false, status: 0, error: timedOutMsg };
|
|
466
|
+
return {
|
|
467
|
+
ok: false,
|
|
468
|
+
status: 0,
|
|
469
|
+
outcomeUnknown: true,
|
|
470
|
+
error: `${timedOutMsg} -- the outcome is unknown: Caddy may still be applying this change. A config change blocks until Caddy finishes reloading, and a client timeout does not cancel it, so it may have applied, may yet apply, or may not apply at all; caddy-mcp never replays a timed-out config change. Re-read the config before retrying. If reloads on this instance legitimately take this long, raise CADDY_LOAD_TIMEOUT, keeping it below your MCP client's request timeout (60 s by default in the MCP SDK), or this error never reaches the client.`
|
|
471
|
+
};
|
|
330
472
|
}
|
|
331
473
|
return { ok: false, status: 0, error: msg };
|
|
332
474
|
}
|
|
@@ -372,20 +514,23 @@ function getRequestTimeout() {
|
|
|
372
514
|
}
|
|
373
515
|
function getLoadTimeout() {
|
|
374
516
|
const raw = process.env.CADDY_LOAD_TIMEOUT;
|
|
375
|
-
if (raw === void 0) return
|
|
517
|
+
if (raw === void 0) return LOAD_TIMEOUT;
|
|
376
518
|
const n = Number(raw);
|
|
377
|
-
if (!Number.isFinite(n)) return
|
|
519
|
+
if (!Number.isFinite(n)) return LOAD_TIMEOUT;
|
|
378
520
|
const floored = Math.floor(n);
|
|
379
|
-
if (floored < 1) return
|
|
521
|
+
if (floored < 1) return LOAD_TIMEOUT;
|
|
380
522
|
return floored;
|
|
381
523
|
}
|
|
524
|
+
function getTimeoutFor(method, path) {
|
|
525
|
+
return isConfigChange(method, path) ? getLoadTimeout() : getRequestTimeout();
|
|
526
|
+
}
|
|
382
527
|
async function loadConfig(config, contentType) {
|
|
383
|
-
const res = await caddyRequest("POST", "/load", config, contentType,
|
|
528
|
+
const res = await caddyRequest("POST", "/load", config, contentType, true);
|
|
384
529
|
if (res.ok) etagCache.clear();
|
|
385
530
|
return res;
|
|
386
531
|
}
|
|
387
532
|
function adapt(config, adapter = "caddyfile") {
|
|
388
|
-
return caddyRequest("POST", "/adapt", config, `text/${adapter}`,
|
|
533
|
+
return caddyRequest("POST", "/adapt", config, `text/${adapter}`, true);
|
|
389
534
|
}
|
|
390
535
|
function stop() {
|
|
391
536
|
return caddyRequest("POST", "/stop");
|
|
@@ -439,28 +584,132 @@ function getMetrics() {
|
|
|
439
584
|
import { z } from "zod";
|
|
440
585
|
|
|
441
586
|
// src/format.ts
|
|
587
|
+
var WARNING_KEYS = /* @__PURE__ */ new Set(["file", "line", "directive", "message"]);
|
|
588
|
+
function formatWarning(w) {
|
|
589
|
+
const asJson = () => ` - ${JSON.stringify(w)}`;
|
|
590
|
+
if (w === null || typeof w !== "object" || Array.isArray(w)) return asJson();
|
|
591
|
+
const obj = w;
|
|
592
|
+
if (Object.keys(obj).some((key) => !WARNING_KEYS.has(key))) return asJson();
|
|
593
|
+
const { file, line, directive, message } = obj;
|
|
594
|
+
if (typeof message !== "string" || message === "") return asJson();
|
|
595
|
+
if (file !== void 0 && typeof file !== "string") return asJson();
|
|
596
|
+
if (line !== void 0 && typeof line !== "number") return asJson();
|
|
597
|
+
if (directive !== void 0 && typeof directive !== "string") return asJson();
|
|
598
|
+
const where = file && line !== void 0 ? `${file}:${line}` : file || (line !== void 0 ? `line ${line}` : "");
|
|
599
|
+
const prefix = [where, directive ? `(${directive})` : ""].filter(Boolean).join(" ");
|
|
600
|
+
return ` - ${prefix ? `${prefix}: ` : ""}${message}`;
|
|
601
|
+
}
|
|
602
|
+
function formatWarnings(warnings) {
|
|
603
|
+
if (!warnings || warnings.length === 0) return "";
|
|
604
|
+
return `
|
|
605
|
+
|
|
606
|
+
Adapter warnings (${warnings.length}):
|
|
607
|
+
${warnings.map(formatWarning).join("\n")}`;
|
|
608
|
+
}
|
|
442
609
|
function formatResult(res) {
|
|
610
|
+
const warnings = formatWarnings(res.warnings);
|
|
443
611
|
if (!res.ok) {
|
|
444
612
|
return {
|
|
445
613
|
isError: true,
|
|
446
|
-
content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
|
|
614
|
+
content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}${warnings}` }]
|
|
447
615
|
};
|
|
448
616
|
}
|
|
449
617
|
const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
|
|
450
618
|
const text = raw || "OK";
|
|
451
|
-
return { content: [{ type: "text", text }] };
|
|
619
|
+
return { content: [{ type: "text", text: `${text}${warnings}` }] };
|
|
452
620
|
}
|
|
453
621
|
|
|
454
622
|
// src/tools/operational.ts
|
|
455
|
-
var
|
|
456
|
-
|
|
623
|
+
var DEFAULT_HTTP_PORT = 80;
|
|
624
|
+
var DEFAULT_HTTPS_PORT = 443;
|
|
625
|
+
function appPort(value, fallback) {
|
|
626
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535 ? value : fallback;
|
|
627
|
+
}
|
|
628
|
+
function splitPort(hostport) {
|
|
629
|
+
const i = hostport.lastIndexOf(":");
|
|
630
|
+
if (i < 0) return void 0;
|
|
631
|
+
if (hostport.startsWith("[")) {
|
|
632
|
+
const end = hostport.indexOf("]");
|
|
633
|
+
if (end < 0 || end + 1 !== i) return void 0;
|
|
634
|
+
if (hostport.slice(1).includes("[") || hostport.slice(end + 1).includes("]")) return void 0;
|
|
635
|
+
} else {
|
|
636
|
+
if (hostport.slice(0, i).includes(":")) return void 0;
|
|
637
|
+
if (hostport.includes("[") || hostport.includes("]")) return void 0;
|
|
638
|
+
}
|
|
639
|
+
return hostport.slice(i + 1);
|
|
640
|
+
}
|
|
641
|
+
function parseListenPortRange(entry) {
|
|
642
|
+
let rest = entry;
|
|
643
|
+
const slash = entry.indexOf("/");
|
|
644
|
+
if (slash >= 0) {
|
|
645
|
+
const network = entry.slice(0, slash).trim().toLowerCase();
|
|
646
|
+
rest = entry.slice(slash + 1);
|
|
647
|
+
if (network.startsWith("unix") || network.startsWith("fd")) return { start: 0, end: 0 };
|
|
648
|
+
}
|
|
649
|
+
const port = splitPort(rest);
|
|
650
|
+
if (port === void 0 || port === "") return { start: 0, end: 0 };
|
|
651
|
+
const dash = port.indexOf("-");
|
|
652
|
+
const start = parseUint16(dash < 0 ? port : port.slice(0, dash));
|
|
653
|
+
const end = parseUint16(dash < 0 ? port : port.slice(dash + 1));
|
|
654
|
+
if (start === void 0 || end === void 0 || end < start) return void 0;
|
|
655
|
+
return { start, end };
|
|
656
|
+
}
|
|
657
|
+
function parseUint16(s) {
|
|
658
|
+
if (!/^[0-9]+$/.test(s)) return void 0;
|
|
659
|
+
const n = Number(s);
|
|
660
|
+
return n <= 65535 ? n : void 0;
|
|
661
|
+
}
|
|
662
|
+
function hasQualifyingHost(routes, skip) {
|
|
663
|
+
for (const route of routes) {
|
|
664
|
+
if (!route || typeof route !== "object") continue;
|
|
665
|
+
const matcherSets = route.match;
|
|
666
|
+
if (!Array.isArray(matcherSets)) continue;
|
|
667
|
+
for (const set of matcherSets) {
|
|
668
|
+
if (!set || typeof set !== "object") continue;
|
|
669
|
+
const hosts = set.host;
|
|
670
|
+
if (!Array.isArray(hosts)) continue;
|
|
671
|
+
for (const host of hosts) {
|
|
672
|
+
if (typeof host === "string" && !skip.has(host)) return true;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
function describeServer(rawValue, httpPort = DEFAULT_HTTP_PORT, httpsPort = DEFAULT_HTTPS_PORT) {
|
|
457
679
|
const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
|
458
680
|
const listen = Array.isArray(raw.listen) ? raw.listen : [];
|
|
459
681
|
const routes = Array.isArray(raw.routes) ? raw.routes : [];
|
|
682
|
+
const ranges = [];
|
|
683
|
+
for (const entry of listen) {
|
|
684
|
+
if (typeof entry !== "string") continue;
|
|
685
|
+
const range = parseListenPortRange(entry);
|
|
686
|
+
if (range) ranges.push(range);
|
|
687
|
+
}
|
|
688
|
+
const usesAnyPortOtherThan = (port) => ranges.some((r) => port > r.end || port < r.start);
|
|
689
|
+
const bindsPort = (port) => ranges.some((r) => r.start <= port && port <= r.end);
|
|
690
|
+
const autoHttps = raw.automatic_https !== null && typeof raw.automatic_https === "object" && !Array.isArray(raw.automatic_https) ? raw.automatic_https : {};
|
|
691
|
+
const skip = new Set(
|
|
692
|
+
Array.isArray(autoHttps.skip) ? autoHttps.skip.filter((s) => typeof s === "string") : []
|
|
693
|
+
);
|
|
694
|
+
const allSocketsOnHttpPort = (port) => ranges.length > 0 && ranges.every((r) => r.start === port && r.end === port);
|
|
695
|
+
const perListener = (label) => allSocketsOnHttpPort(httpPort) ? "enabled (no listener gets TLS: all are on the HTTP port)" : bindsPort(httpPort) ? "mixed (TLS on non-HTTP listeners only)" : label;
|
|
460
696
|
const tlsPolicies = raw.tls_connection_policies;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
697
|
+
let tls;
|
|
698
|
+
if (Array.isArray(tlsPolicies) && tlsPolicies.length === 0) {
|
|
699
|
+
tls = "off (empty tls_connection_policies)";
|
|
700
|
+
} else if (tlsPolicies) {
|
|
701
|
+
tls = perListener("enabled");
|
|
702
|
+
} else if (autoHttps.disable === true) {
|
|
703
|
+
tls = "off (automatic HTTPS disabled)";
|
|
704
|
+
} else if (!usesAnyPortOtherThan(httpPort)) {
|
|
705
|
+
tls = "off (HTTP only)";
|
|
706
|
+
} else if (!usesAnyPortOtherThan(httpsPort)) {
|
|
707
|
+
tls = perListener("auto (HTTPS)");
|
|
708
|
+
} else if (hasQualifyingHost(routes, skip)) {
|
|
709
|
+
tls = perListener("auto (HTTPS: host matchers on a non-HTTP port)");
|
|
710
|
+
} else {
|
|
711
|
+
tls = "off (no host matchers)";
|
|
712
|
+
}
|
|
464
713
|
const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
|
|
465
714
|
return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
|
|
466
715
|
}
|
|
@@ -519,14 +768,17 @@ function registerOperationalTools(server) {
|
|
|
519
768
|
const res = await configGet();
|
|
520
769
|
if (!res.ok) return formatResult(res);
|
|
521
770
|
const config = res.data ?? {};
|
|
522
|
-
const
|
|
771
|
+
const httpApp = config.apps?.http;
|
|
772
|
+
const servers = httpApp?.servers ?? {};
|
|
523
773
|
const serverNames = Object.keys(servers);
|
|
774
|
+
const httpPort = appPort(httpApp?.http_port, DEFAULT_HTTP_PORT);
|
|
775
|
+
const httpsPort = appPort(httpApp?.https_port, DEFAULT_HTTPS_PORT);
|
|
524
776
|
const lines = ["Caddy is running", ""];
|
|
525
777
|
if (serverNames.length === 0) {
|
|
526
778
|
lines.push("No HTTP servers configured");
|
|
527
779
|
} else {
|
|
528
780
|
for (const name of serverNames) {
|
|
529
|
-
lines.push(`Server "${name}": ${describeServer(servers[name])}`);
|
|
781
|
+
lines.push(`Server "${name}": ${describeServer(servers[name], httpPort, httpsPort)}`);
|
|
530
782
|
}
|
|
531
783
|
}
|
|
532
784
|
const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
|
|
@@ -560,7 +812,7 @@ ${lines.join("\n")}` }]
|
|
|
560
812
|
);
|
|
561
813
|
server.tool(
|
|
562
814
|
"caddy_upstreams",
|
|
563
|
-
"
|
|
815
|
+
"Caddy's /reverse_proxy/upstreams array (address, num_requests, fails), returned verbatim. On Caddy 2.11.2+ it is not the configured upstream list. Dynamic-upstream backends stay listed about 1 h (up to ~65 min) after the dynamic source last returned them, even after a config change removes them, so an address may appear that no current config references. A backend with requests in flight can appear twice when its resolved address differs from the entry's text (dynamic upstreams, tcp/ or unix// dials, placeholder dials). That extra copy always shows fails 0 and repeats num_requests, so do not sum num_requests across entries.",
|
|
564
816
|
{},
|
|
565
817
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
566
818
|
async () => formatResult(await getUpstreams())
|
|
@@ -634,7 +886,9 @@ function registerResources(server) {
|
|
|
634
886
|
server.resource(
|
|
635
887
|
"caddy-upstreams",
|
|
636
888
|
"caddy://upstreams",
|
|
637
|
-
{
|
|
889
|
+
{
|
|
890
|
+
description: "Reverse proxy upstream health: Caddy's /reverse_proxy/upstreams array, returned verbatim. On Caddy 2.11.2+ it is not the configured upstream list -- dynamic upstreams stay listed about 1 h after the dynamic source last returned them (so an address can outlive the config that referenced it), and a backend with requests in flight can appear twice when its resolved address differs from the entry's text. The extra copy always shows fails 0 and repeats num_requests, so do not sum num_requests. See the caddy_upstreams tool for the full caveat."
|
|
891
|
+
},
|
|
638
892
|
async () => {
|
|
639
893
|
const res = await getUpstreams();
|
|
640
894
|
return {
|
|
@@ -700,7 +954,7 @@ function registerResources(server) {
|
|
|
700
954
|
|
|
701
955
|
// src/tools/adapt.ts
|
|
702
956
|
import { z as z2 } from "zod";
|
|
703
|
-
function
|
|
957
|
+
function formatWarning2(w) {
|
|
704
958
|
if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
|
|
705
959
|
const obj = w;
|
|
706
960
|
const directive = typeof obj.directive === "string" ? obj.directive : "unknown";
|
|
@@ -710,7 +964,7 @@ function formatWarning(w) {
|
|
|
710
964
|
function registerAdaptTools(server) {
|
|
711
965
|
server.tool(
|
|
712
966
|
"caddy_adapt",
|
|
713
|
-
|
|
967
|
+
`Convert a config in any registered adapter format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces, or for porting from nginx/yaml configs when Caddy is built with the matching adapter module ('caddyfile' is built-in; 'nginx', 'yaml', etc. require their adapter modules to be compiled into the Caddy binary). Returns the adapted JSON and any warnings separately. One exception to 'without loading it': on Caddy <= 2.11.4 a Caddyfile 'order' global option is not preview-only. It mutates that Caddy process's directive order, so it carries into every later Caddyfile adapt or load there, and an 'order' line that FAILS (unknown target, bad positional, extra arg) still removes the directive it names -- later Caddyfiles using that directive then fail with "directive 'X' is not an ordered HTTP handler" until another 'order' line re-places it or Caddy restarts. Fixed upstream in caddyserver/caddy#7995, unreleased as of v2.11.4.`,
|
|
714
968
|
{
|
|
715
969
|
config: z2.string().describe("The raw config text (e.g., Caddyfile contents, nginx.conf, yaml)"),
|
|
716
970
|
adapter: z2.string().regex(/^[a-z0-9_-]+$/, "Adapter must be lowercase alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe(
|
|
@@ -726,7 +980,7 @@ function registerAdaptTools(server) {
|
|
|
726
980
|
const result = data.result;
|
|
727
981
|
const content = [];
|
|
728
982
|
if (warnings.length > 0) {
|
|
729
|
-
const warnLines = warnings.map(
|
|
983
|
+
const warnLines = warnings.map(formatWarning2);
|
|
730
984
|
content.push({ type: "text", text: `Warnings:
|
|
731
985
|
${warnLines.join("\n")}` });
|
|
732
986
|
}
|
|
@@ -820,6 +1074,9 @@ function getSnapshot(index) {
|
|
|
820
1074
|
}
|
|
821
1075
|
|
|
822
1076
|
// src/tools/config.ts
|
|
1077
|
+
function isRootConfigPath(path) {
|
|
1078
|
+
return /^\/*$/.test(path.replace(/^\/?(config(\/|$))?/, ""));
|
|
1079
|
+
}
|
|
823
1080
|
function registerConfigTools(server) {
|
|
824
1081
|
server.tool(
|
|
825
1082
|
"caddy_config_get",
|
|
@@ -830,12 +1087,12 @@ function registerConfigTools(server) {
|
|
|
830
1087
|
);
|
|
831
1088
|
server.tool(
|
|
832
1089
|
"caddy_config_set",
|
|
833
|
-
"Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. Mode 'append' adds to
|
|
1090
|
+
"Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. Mode 'append' (POST) adds to an array \u2014 NOT idempotent: calling twice with the same route duplicates it \u2014 but on a non-array key it REPLACES whatever is there, and it cannot create missing parent objects. Mode 'insert' (PUT) inserts at an array index (useful for route ordering), or strictly creates an object key together with any missing parents and fails with 409 if the key already exists \u2014 the safe way to create a server or app, including on an instance with no config at all.",
|
|
834
1091
|
{
|
|
835
1092
|
path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
|
|
836
1093
|
value: z3.any().describe("The JSON value to set at the path"),
|
|
837
1094
|
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
838
|
-
"'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (
|
|
1095
|
+
"'overwrite' = PATCH (replace existing, default, idempotent; 404 if the key does not exist), 'append' = POST (appends to arrays, NOT idempotent; REPLACES an existing non-array key; cannot create missing parents), 'insert' = PUT (inserts at an array index, or strictly creates an object key and any missing parents; 409 if the key exists)"
|
|
839
1096
|
)
|
|
840
1097
|
},
|
|
841
1098
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
@@ -846,7 +1103,7 @@ function registerConfigTools(server) {
|
|
|
846
1103
|
);
|
|
847
1104
|
server.tool(
|
|
848
1105
|
"caddy_config_delete",
|
|
849
|
-
"Delete config at a JSON path. Removes the config node at the specified path. Deleting a parent node also deletes every descendant -- e.g. deleting 'apps/http/servers/srv0' removes that server and all of its routes. Requires confirm=true.",
|
|
1106
|
+
"Delete config at a JSON path. Removes the config node at the specified path. Deleting a parent node also deletes every descendant -- e.g. deleting 'apps/http/servers/srv0' removes that server and all of its routes. Requires confirm=true. Any path that addresses the config ROOT ('', '/', 'config', '/config/', and slash-only variants of those) addresses the ENTIRE config and unloads it: every app and server goes, and so does the 'admin' block, after which Caddy re-binds its admin endpoint to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment). If CADDY_ADMIN_URL points anywhere else, neither this server nor caddy_revert can reach Caddy afterwards. A root delete is snapshotted first, so caddy_revert can restore it while Caddy is still reachable; no other path is snapshotted. To REPLACE the config rather than unload it, use caddy_load.",
|
|
850
1107
|
{
|
|
851
1108
|
path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
|
|
852
1109
|
confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
|
|
@@ -857,27 +1114,54 @@ function registerConfigTools(server) {
|
|
|
857
1114
|
// array after a delete, so repeating that call removes a DIFFERENT route each
|
|
858
1115
|
// time. caddy_remove_route carries the same correction for the byte-identical
|
|
859
1116
|
// underlying request; the two must agree. Nothing here is auto-recoverable
|
|
860
|
-
// either:
|
|
861
|
-
// undone with caddy_revert.
|
|
1117
|
+
// either: apart from a root delete (below), only caddy_load captures a
|
|
1118
|
+
// snapshot, so a spurious repeat cannot be undone with caddy_revert.
|
|
862
1119
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
863
1120
|
async ({ path, confirm }) => {
|
|
1121
|
+
const root = isRootConfigPath(path);
|
|
864
1122
|
if (!confirm) {
|
|
865
1123
|
return {
|
|
866
1124
|
isError: true,
|
|
867
1125
|
content: [
|
|
868
1126
|
{
|
|
869
1127
|
type: "text",
|
|
870
|
-
text: `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
|
|
1128
|
+
text: root ? `Refusing to delete the ENTIRE config without confirm=true. The path "${path}" addresses the config root, so this unloads every app and server, and the 'admin' block with them: Caddy then re-binds its admin endpoint to its default address (localhost:2019, or $CADDY_ADMIN in Caddy's environment), and if CADDY_ADMIN_URL points anywhere else neither this server nor caddy_revert can reach Caddy afterwards. The current config is snapshotted first, so caddy_revert can restore it while Caddy is still reachable. To REPLACE the config rather than unload it, use caddy_load. Re-run with confirm:true to proceed.` : `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
|
|
871
1129
|
}
|
|
872
1130
|
]
|
|
873
1131
|
};
|
|
874
1132
|
}
|
|
875
|
-
return formatResult(await configDelete(path));
|
|
1133
|
+
if (!root) return formatResult(await configDelete(path));
|
|
1134
|
+
const current = await configGet();
|
|
1135
|
+
const res = await configDelete("");
|
|
1136
|
+
const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
|
|
1137
|
+
if (kept) saveSnapshot(current.data, "caddy_config_delete");
|
|
1138
|
+
if (!res.ok) {
|
|
1139
|
+
if (res.outcomeUnknown && kept) {
|
|
1140
|
+
return formatResult({
|
|
1141
|
+
...res,
|
|
1142
|
+
error: `${res.error}
|
|
1143
|
+
The pre-delete config was kept anyway, as snapshot [0] (trigger=caddy_config_delete): if this delete did apply, caddy_revert { action: "apply", index: 0, confirm: true } restores what it unloaded -- as long as this server can still reach Caddy's admin endpoint, which moves back to Caddy's default address when the deleted config set an admin.listen of its own. If the delete did not apply, that snapshot is simply the config read just before it was sent.`
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
return formatResult(res);
|
|
1147
|
+
}
|
|
1148
|
+
const note = kept ? ` The prior config was saved as snapshot [0] (trigger=caddy_config_delete): caddy_revert { action: "apply", index: 0, confirm: true } restores it.` : current.ok ? " Warning: the prior config was empty or not a JSON object, so no snapshot was captured -- there was nothing to restore." : " Warning: the prior config could not be read, so no snapshot was captured and this unload cannot be reverted.";
|
|
1149
|
+
const adm = kept ? current.data.admin : void 0;
|
|
1150
|
+
const listen = adm !== null && typeof adm === "object" && !Array.isArray(adm) ? adm.listen : void 0;
|
|
1151
|
+
const adminNote = typeof listen === "string" && listen !== "" ? ` The unloaded config set admin.listen to "${listen}". If that was not already Caddy's default admin address, the endpoint has moved back to the default (localhost:2019, unless $CADDY_ADMIN is set in Caddy's environment) and CADDY_ADMIN_URL (${process.env.CADDY_ADMIN_URL || "http://localhost:2019"}) may no longer reach it.` : "";
|
|
1152
|
+
return {
|
|
1153
|
+
content: [
|
|
1154
|
+
{
|
|
1155
|
+
type: "text",
|
|
1156
|
+
text: `Unloaded the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
|
|
1157
|
+
}
|
|
1158
|
+
]
|
|
1159
|
+
};
|
|
876
1160
|
}
|
|
877
1161
|
);
|
|
878
1162
|
server.tool(
|
|
879
1163
|
"caddy_load",
|
|
880
|
-
"Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes.
|
|
1164
|
+
"Replace the entire Caddy configuration atomically. Accepts a JSON config object, or a Caddyfile string with format='caddyfile'. This is the safest way to make large config changes. Runs on CADDY_LOAD_TIMEOUT (55 s by default), like every config change; a load that times out is not retried and may still apply, so re-read the config before loading again. Requires confirm=true: this DISCARDS the entire running config, including servers and routes not present in the supplied config. The prior config is snapshotted first and can be restored with caddy_revert.",
|
|
881
1165
|
{
|
|
882
1166
|
config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
|
|
883
1167
|
format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'"),
|
|
@@ -899,15 +1183,21 @@ function registerConfigTools(server) {
|
|
|
899
1183
|
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
900
1184
|
const current = await configGet();
|
|
901
1185
|
const res = await loadConfig(config, contentType);
|
|
902
|
-
|
|
903
|
-
|
|
1186
|
+
const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
|
|
1187
|
+
if (kept) saveSnapshot(current.data, "caddy_load");
|
|
1188
|
+
if (res.outcomeUnknown && kept) {
|
|
1189
|
+
return formatResult({
|
|
1190
|
+
...res,
|
|
1191
|
+
error: `${res.error}
|
|
1192
|
+
The pre-load config was kept anyway, as snapshot [0] (trigger=caddy_load): if this load did apply, caddy_revert { action: "apply", index: 0, confirm: true } restores what it replaced. If it did not, that snapshot is simply the config read just before the load was sent.`
|
|
1193
|
+
});
|
|
904
1194
|
}
|
|
905
1195
|
return formatResult(res);
|
|
906
1196
|
}
|
|
907
1197
|
);
|
|
908
1198
|
server.tool(
|
|
909
1199
|
"caddy_revert",
|
|
910
|
-
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load (
|
|
1200
|
+
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load, and before a caddy_config_delete that unloads the whole config (an empty path); no other delete is snapshotted. 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).",
|
|
911
1201
|
{
|
|
912
1202
|
action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
|
|
913
1203
|
index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
|
|
@@ -970,7 +1260,19 @@ ${lines.join("\n")}` }] };
|
|
|
970
1260
|
}
|
|
971
1261
|
const current = await configGet();
|
|
972
1262
|
const res = await loadConfig(snap.config, "application/json");
|
|
973
|
-
if (!res.ok)
|
|
1263
|
+
if (!res.ok) {
|
|
1264
|
+
if (res.outcomeUnknown && current.ok && isSnapshotableConfig(current.data)) {
|
|
1265
|
+
saveSnapshot(current.data, "caddy_revert");
|
|
1266
|
+
const now = listSnapshots().indexOf(snap);
|
|
1267
|
+
const target = now === -1 ? `the snapshot you asked to apply ([${index}]) has dropped out of the ring` : `the snapshot you asked to apply is now [${now}]`;
|
|
1268
|
+
return formatResult({
|
|
1269
|
+
...res,
|
|
1270
|
+
error: `${res.error}
|
|
1271
|
+
The pre-revert config was kept anyway, as snapshot [0] (trigger=caddy_revert), so this revert can still be rolled back if it did apply. That moved every older snapshot down one index: ${target}, so re-running apply with index ${index} would load a different snapshot. List the snapshots before retrying.`
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
return formatResult(res);
|
|
1275
|
+
}
|
|
974
1276
|
const capturedRollforward = current.ok && isSnapshotableConfig(current.data);
|
|
975
1277
|
if (capturedRollforward) {
|
|
976
1278
|
saveSnapshot(current.data, "caddy_revert");
|
|
@@ -994,7 +1296,7 @@ ${lines.join("\n")}` }] };
|
|
|
994
1296
|
value: z3.any().optional().describe("New value (required for 'set' action)"),
|
|
995
1297
|
subpath: z3.string().optional().default("").describe("Optional sub-path within the identified object"),
|
|
996
1298
|
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
997
|
-
"For 'set' action: 'overwrite' = PATCH (replace
|
|
1299
|
+
"For 'set' action: 'overwrite' = PATCH (replace the identified object, or the value at subpath; default). 'append' = POST and 'insert' = PUT behave as in caddy_config_set at the resolved path: with a subpath into an array, POST appends and PUT inserts at the index; PUT also strictly creates an object key (409 if it exists). With NO subpath: for an array element (a route) neither replaces it \u2014 POST adds the value as a new element at the end of that array, PUT inserts it just before the identified one, and both are rejected with 'duplicate ID' if the value carries the same @id; for an object held under a key (a server) POST REPLACES it wholesale and PUT fails with 409. Use 'overwrite' to replace in place."
|
|
998
1300
|
),
|
|
999
1301
|
confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
|
|
1000
1302
|
},
|
|
@@ -1150,7 +1452,7 @@ function serverNotFoundError(srv, op = "operation") {
|
|
|
1150
1452
|
content: [
|
|
1151
1453
|
{
|
|
1152
1454
|
type: "text",
|
|
1153
|
-
text: `Error: Server "${srv}" does not exist (${op}). Use caddy_list_servers to see what is configured. To create it: caddy_config_set { path: "apps/http/servers/${srv}", mode: "
|
|
1455
|
+
text: `Error: Server "${srv}" does not exist (${op}). Use caddy_list_servers to see what is configured. To create it: caddy_config_set { path: "apps/http/servers/${srv}", mode: "insert", value: { "listen": [":443"], "routes": [] } }. Both arguments are load-bearing: mode "insert" (PUT) creates the key along with any missing apps/http/servers parents, so it works even on an instance with no config at all, and fails with 409 if the server already exists -- whereas "append" (POST) would replace an existing server and cannot create missing parents, and the default "overwrite" fails with "key does not exist"; and "routes": [] must be present, or adding the first route fails, because a POST creates a missing routes key as an object rather than an array.`
|
|
1154
1456
|
}
|
|
1155
1457
|
]
|
|
1156
1458
|
};
|
|
@@ -1161,7 +1463,7 @@ function serverNullError(srv) {
|
|
|
1161
1463
|
content: [
|
|
1162
1464
|
{
|
|
1163
1465
|
type: "text",
|
|
1164
|
-
text: `Error: Server "${srv}" is not configured, or its config is null -- Caddy returns the same response (HTTP 200 with a body of null) for both, so they cannot be told apart from here. Use caddy_list_servers to see which servers exist
|
|
1466
|
+
text: `Error: Server "${srv}" is not configured, or its config is null -- Caddy returns the same response (HTTP 200 with a body of null) for both, so they cannot be told apart from here. Use caddy_list_servers to see which servers exist. To create this one: caddy_config_set { path: "apps/http/servers/${srv}", mode: "insert", value: { "listen": [":443"], "routes": [] } }. Mode "insert" (PUT) strictly creates the key, so it never overwrites anything. If it fails with 409 "key already exists", the key is there now: it may hold a null config, or it may have been created after this read (by another writer, or by an earlier attempt of this same write whose response was lost). Re-read it with caddy_config_get { path: "apps/http/servers/${srv}" }, and use mode "overwrite" only if that read still shows null -- overwrite (PATCH) replaces the whole server, routes included.`
|
|
1165
1467
|
}
|
|
1166
1468
|
]
|
|
1167
1469
|
};
|
|
@@ -1544,7 +1846,7 @@ function buildTlsConfig(fields) {
|
|
|
1544
1846
|
}
|
|
1545
1847
|
};
|
|
1546
1848
|
}
|
|
1547
|
-
function bothErrors(label, patchRes, writeRes, writeLabel) {
|
|
1849
|
+
function bothErrors(label, patchRes, writeRes, writeLabel, hint) {
|
|
1548
1850
|
const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
|
|
1549
1851
|
const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
|
|
1550
1852
|
return {
|
|
@@ -1554,11 +1856,16 @@ function bothErrors(label, patchRes, writeRes, writeLabel) {
|
|
|
1554
1856
|
type: "text",
|
|
1555
1857
|
text: `Error: Failed to set ${label}.
|
|
1556
1858
|
PATCH attempt: ${patchErr}
|
|
1557
|
-
${writeLabel} fallback: ${writeErr}`
|
|
1859
|
+
${writeLabel} fallback: ${writeErr}` + (hint ? `
|
|
1860
|
+
${hint}` : "")
|
|
1558
1861
|
}
|
|
1559
1862
|
]
|
|
1560
1863
|
};
|
|
1561
1864
|
}
|
|
1865
|
+
function isAbsentOnGet(res) {
|
|
1866
|
+
if (res.ok) return res.data === void 0 || res.data === null;
|
|
1867
|
+
return isMissingConfigPath(res);
|
|
1868
|
+
}
|
|
1562
1869
|
function isPlainObject(v) {
|
|
1563
1870
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1564
1871
|
}
|
|
@@ -1621,13 +1928,15 @@ function refuseFallback(label, patchRes, detail) {
|
|
|
1621
1928
|
}
|
|
1622
1929
|
};
|
|
1623
1930
|
}
|
|
1931
|
+
var CREATE_CONFLICT_HINT = "apps/tls read as not set, but Caddy now reports the key exists, so nothing was overwritten. Either something else created it between the read and this write, or an earlier attempt of this same write landed and only its response was lost. Check caddy_tls status, then re-run this action so it merges into what is there instead of creating it.";
|
|
1624
1932
|
async function safeFallback(label, patchRes, fields) {
|
|
1625
1933
|
const getRes = await configGet("apps/tls");
|
|
1626
|
-
const absent =
|
|
1934
|
+
const absent = isAbsentOnGet(getRes);
|
|
1627
1935
|
if (absent) {
|
|
1628
|
-
const
|
|
1629
|
-
if (
|
|
1630
|
-
|
|
1936
|
+
const putRes = await configPut("apps/tls", buildTlsConfig(fields));
|
|
1937
|
+
if (putRes.ok) return { kind: "ok" };
|
|
1938
|
+
const hint = putRes.status === 409 ? CREATE_CONFLICT_HINT : void 0;
|
|
1939
|
+
return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT", hint) };
|
|
1631
1940
|
}
|
|
1632
1941
|
if (!getRes.ok) {
|
|
1633
1942
|
return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
|
|
@@ -1669,29 +1978,39 @@ function missingArgError(text) {
|
|
|
1669
1978
|
function registerTlsTools(server) {
|
|
1670
1979
|
server.tool(
|
|
1671
1980
|
"caddy_tls",
|
|
1672
|
-
"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, and only when that issuer's module is 'acme' -- on a multi-policy TLS config, or one whose first issuer is 'internal' (Caddy's local CA), edit the intended issuer with caddy_config_set instead.",
|
|
1981
|
+
"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 at apps/tls/encrypted_client_hello (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances, including one with no config at all: the set_* actions create apps/tls, and any missing parents, when it is not set. Writes target policies[0].issuers[0] only, and only when that issuer's module is 'acme' -- on a multi-policy TLS config, or one whose first issuer is 'internal' (Caddy's local CA), edit the intended issuer with caddy_config_set instead.",
|
|
1673
1982
|
{
|
|
1674
1983
|
action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
|
|
1675
1984
|
email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
|
|
1676
1985
|
ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)"),
|
|
1677
1986
|
profile: z5.string().optional().describe(
|
|
1678
|
-
"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."
|
|
1987
|
+
"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. EXPERIMENTAL upstream (the ACME profiles spec is still a draft; Caddy marks the field 'subject to change' and may rename or drop it). Caddy accepts any name on load, so a success here does not mean the CA offers it: if this issuer's CA does not advertise the name, every order from this issuer fails at issuance time, reported only in Caddy's own logs. Caddy then either falls through to the next issuer in the policy, which issues WITHOUT the profile, or -- when this is the policy's only issuer, which is the shape this tool creates -- keeps retrying and issues no certificate at all."
|
|
1679
1988
|
)
|
|
1680
1989
|
},
|
|
1681
1990
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1682
1991
|
async ({ action, email, ca, profile }) => {
|
|
1683
1992
|
if (action === "status") {
|
|
1684
|
-
|
|
1993
|
+
const tlsRes = await configGet("apps/tls");
|
|
1994
|
+
if (isAbsentOnGet(tlsRes)) {
|
|
1995
|
+
return {
|
|
1996
|
+
content: [
|
|
1997
|
+
{
|
|
1998
|
+
type: "text",
|
|
1999
|
+
text: "No TLS config is set on this instance (apps/tls is not set), so Caddy's TLS defaults apply. The set_email / set_acme_ca / set_acme_profile actions create it."
|
|
2000
|
+
}
|
|
2001
|
+
]
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
return formatResult(tlsRes);
|
|
1685
2005
|
}
|
|
1686
2006
|
if (action === "ech_status") {
|
|
1687
|
-
const echRes = await configGet("apps/tls/
|
|
1688
|
-
|
|
1689
|
-
if (absent) {
|
|
2007
|
+
const echRes = await configGet("apps/tls/encrypted_client_hello");
|
|
2008
|
+
if (isAbsentOnGet(echRes)) {
|
|
1690
2009
|
return {
|
|
1691
2010
|
content: [
|
|
1692
2011
|
{
|
|
1693
2012
|
type: "text",
|
|
1694
|
-
text: "ECH (Encrypted ClientHello) is not configured on this instance. Requires Caddy 2.10+; enable it by applying a config
|
|
2013
|
+
text: "ECH (Encrypted ClientHello) is not configured on this instance: apps/tls/encrypted_client_hello is not set. Requires Caddy 2.10+; enable it by applying a config that sets apps.tls.encrypted_client_hello via caddy_load. The JSON key is 'encrypted_client_hello' -- 'ech' is only the Caddyfile global option name, and Caddy rejects apps.tls.ech as an unknown field."
|
|
1695
2014
|
}
|
|
1696
2015
|
]
|
|
1697
2016
|
};
|