@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/index.js
CHANGED
|
@@ -14,6 +14,7 @@ var RETRY_MAX_DELAY_MS = 2e3;
|
|
|
14
14
|
var RETRY_MAX_JITTER_MS = 50;
|
|
15
15
|
var RETRY_HARD_CAP = 5;
|
|
16
16
|
var ADMIN_RESTART_SETTLE_MS = 250;
|
|
17
|
+
var LOAD_TIMEOUT = 55e3;
|
|
17
18
|
var etagCache = /* @__PURE__ */ new Map();
|
|
18
19
|
var MAX_ETAG_CACHE = 256;
|
|
19
20
|
function setEtag(path, etag) {
|
|
@@ -23,6 +24,54 @@ function setEtag(path, etag) {
|
|
|
23
24
|
}
|
|
24
25
|
etagCache.set(path, etag);
|
|
25
26
|
}
|
|
27
|
+
var GO_WHITESPACE = /* @__PURE__ */ new Set([
|
|
28
|
+
9,
|
|
29
|
+
10,
|
|
30
|
+
11,
|
|
31
|
+
12,
|
|
32
|
+
13,
|
|
33
|
+
32,
|
|
34
|
+
133,
|
|
35
|
+
160,
|
|
36
|
+
5760,
|
|
37
|
+
8192,
|
|
38
|
+
8193,
|
|
39
|
+
8194,
|
|
40
|
+
8195,
|
|
41
|
+
8196,
|
|
42
|
+
8197,
|
|
43
|
+
8198,
|
|
44
|
+
8199,
|
|
45
|
+
8200,
|
|
46
|
+
8201,
|
|
47
|
+
8202,
|
|
48
|
+
8232,
|
|
49
|
+
8233,
|
|
50
|
+
8239,
|
|
51
|
+
8287,
|
|
52
|
+
12288
|
|
53
|
+
]);
|
|
54
|
+
function goFields(text) {
|
|
55
|
+
const fields = [];
|
|
56
|
+
let current = "";
|
|
57
|
+
for (const ch of text) {
|
|
58
|
+
if (GO_WHITESPACE.has(ch.codePointAt(0))) {
|
|
59
|
+
if (current) fields.push(current);
|
|
60
|
+
current = "";
|
|
61
|
+
} else {
|
|
62
|
+
current += ch;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (current) fields.push(current);
|
|
66
|
+
return fields;
|
|
67
|
+
}
|
|
68
|
+
function isEchoableEtag(etag) {
|
|
69
|
+
const decoded = Buffer.from(etag, "latin1").toString("utf8");
|
|
70
|
+
if (decoded.length < 2 || !decoded.startsWith('"') || !decoded.endsWith('"')) return false;
|
|
71
|
+
const inner = decoded.slice(1, -1);
|
|
72
|
+
const fields = goFields(inner);
|
|
73
|
+
return fields.length === 2 && fields.join(" ") === inner;
|
|
74
|
+
}
|
|
26
75
|
function isAncestorOf(ancestor, descendant) {
|
|
27
76
|
const base = ancestor.endsWith("/") ? ancestor.slice(0, -1) : ancestor;
|
|
28
77
|
return descendant.startsWith(`${base}/`);
|
|
@@ -108,25 +157,36 @@ function sleep(ms) {
|
|
|
108
157
|
function isTransientFailure(res) {
|
|
109
158
|
if (res.ok) return false;
|
|
110
159
|
if (res.status === 0) return true;
|
|
111
|
-
|
|
112
|
-
return false;
|
|
160
|
+
return res.status === 502 || res.status === 503 || res.status === 504;
|
|
113
161
|
}
|
|
114
162
|
function isMissingConfigPath(res) {
|
|
115
163
|
if (res.ok) return false;
|
|
116
164
|
return (res.error ?? "").toLowerCase().includes("invalid traversal path");
|
|
117
165
|
}
|
|
118
|
-
var ARRAY_INDEX_TAIL_RE = /\/\d
|
|
166
|
+
var ARRAY_INDEX_TAIL_RE = /\/\d+(\/+\.\.\.)?\/*$/;
|
|
167
|
+
var BARE_ID_RE = /^\/id\/[^/]+(\/+\.\.\.)?\/*$/;
|
|
168
|
+
function isConfigChange(method, path) {
|
|
169
|
+
if (method === "GET") return false;
|
|
170
|
+
return path === "/load" || path.startsWith("/config/") || path.startsWith("/id/");
|
|
171
|
+
}
|
|
119
172
|
function isRetryableMethod(method, path) {
|
|
120
|
-
if (method === "PUT"
|
|
173
|
+
if (method === "PUT" && BARE_ID_RE.test(path)) return false;
|
|
174
|
+
if (method === "PUT" || method === "DELETE") return !ARRAY_INDEX_TAIL_RE.test(path);
|
|
121
175
|
if (method !== "POST") return true;
|
|
122
176
|
return !path.startsWith("/config/") && !path.startsWith("/id/");
|
|
123
177
|
}
|
|
178
|
+
function shouldRetry(method, path, attempt) {
|
|
179
|
+
if (!isTransientFailure(attempt.res)) return false;
|
|
180
|
+
if (attempt.refused) return true;
|
|
181
|
+
if (attempt.timedOut && isConfigChange(method, path)) return false;
|
|
182
|
+
return isRetryableMethod(method, path);
|
|
183
|
+
}
|
|
124
184
|
function getMalformedUnixUrl() {
|
|
125
185
|
const raw = (process.env.CADDY_ADMIN_URL || "").trim();
|
|
126
186
|
if (!raw || !/^unix[:/]/i.test(raw)) return void 0;
|
|
127
187
|
return getUnixSocketPath() === void 0 ? raw : void 0;
|
|
128
188
|
}
|
|
129
|
-
async function caddyRequest(method, path, body, contentType,
|
|
189
|
+
async function caddyRequest(method, path, body, contentType, rawStringBody = false) {
|
|
130
190
|
const malformed = getMalformedUnixUrl();
|
|
131
191
|
if (malformed) {
|
|
132
192
|
return {
|
|
@@ -136,16 +196,16 @@ async function caddyRequest(method, path, body, contentType, timeout, rawStringB
|
|
|
136
196
|
};
|
|
137
197
|
}
|
|
138
198
|
const maxRetries = getMaxRetries();
|
|
139
|
-
let
|
|
140
|
-
let
|
|
141
|
-
while (
|
|
142
|
-
|
|
143
|
-
const backoff = Math.min(RETRY_BASE_MS * 2 ** (
|
|
199
|
+
let retries = 0;
|
|
200
|
+
let attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
|
|
201
|
+
while (retries < maxRetries && shouldRetry(method, path, attempt)) {
|
|
202
|
+
retries++;
|
|
203
|
+
const backoff = Math.min(RETRY_BASE_MS * 2 ** (retries - 1), RETRY_MAX_DELAY_MS);
|
|
144
204
|
const delay = backoff + Math.random() * RETRY_MAX_JITTER_MS;
|
|
145
205
|
await sleep(delay);
|
|
146
|
-
|
|
206
|
+
attempt = await attemptRequest(method, path, body, contentType, rawStringBody);
|
|
147
207
|
}
|
|
148
|
-
return res;
|
|
208
|
+
return attempt.res;
|
|
149
209
|
}
|
|
150
210
|
function isConnectionRefused(err) {
|
|
151
211
|
let current = err;
|
|
@@ -159,27 +219,44 @@ function isConnectionRefused(err) {
|
|
|
159
219
|
}
|
|
160
220
|
return false;
|
|
161
221
|
}
|
|
222
|
+
function isTimeoutError(err) {
|
|
223
|
+
let current = err;
|
|
224
|
+
for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth++) {
|
|
225
|
+
const e = current;
|
|
226
|
+
if (e.name === "TimeoutError" || e.name === "AbortError") return true;
|
|
227
|
+
current = e.cause;
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
162
231
|
function sendViaUnixSocket(socketPath, path, method, headers, body, timeoutMs) {
|
|
163
232
|
return new Promise((resolve, reject) => {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
233
|
+
let deadlineHit = false;
|
|
234
|
+
const deadline = Object.assign(new Error(`timed out after ${timeoutMs}ms`), { name: "TimeoutError" });
|
|
235
|
+
function fail(err) {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
reject(deadlineHit ? deadline : err);
|
|
238
|
+
}
|
|
239
|
+
const req = httpRequest({ socketPath, path, method, headers, agent: false }, (res) => {
|
|
240
|
+
const chunks = [];
|
|
241
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
242
|
+
res.on("error", fail);
|
|
243
|
+
res.on("end", () => {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
const status = res.statusCode ?? 0;
|
|
246
|
+
const etag = res.headers.etag;
|
|
247
|
+
resolve({
|
|
248
|
+
ok: status >= 200 && status < 300,
|
|
249
|
+
status,
|
|
250
|
+
text: Buffer.concat(chunks).toString("utf8"),
|
|
251
|
+
etag: typeof etag === "string" ? etag : void 0
|
|
179
252
|
});
|
|
180
|
-
}
|
|
181
|
-
);
|
|
182
|
-
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
const timer = setTimeout(() => {
|
|
256
|
+
deadlineHit = true;
|
|
257
|
+
req.destroy(deadline);
|
|
258
|
+
}, timeoutMs);
|
|
259
|
+
req.on("error", fail);
|
|
183
260
|
if (body !== void 0) req.write(body);
|
|
184
261
|
req.end();
|
|
185
262
|
});
|
|
@@ -238,19 +315,65 @@ function settleAdminRestart(origin) {
|
|
|
238
315
|
socketWaiters.add(check);
|
|
239
316
|
});
|
|
240
317
|
}
|
|
241
|
-
function
|
|
242
|
-
|
|
243
|
-
|
|
318
|
+
function endOfFirstJsonValue(text) {
|
|
319
|
+
let depth = 0;
|
|
320
|
+
let inString = false;
|
|
321
|
+
let escaped = false;
|
|
322
|
+
for (let i = 0; i < text.length; i++) {
|
|
323
|
+
const ch = text[i];
|
|
324
|
+
if (inString) {
|
|
325
|
+
if (escaped) escaped = false;
|
|
326
|
+
else if (ch === "\\") escaped = true;
|
|
327
|
+
else if (ch === '"') inString = false;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (ch === '"') inString = true;
|
|
331
|
+
else if (ch === "[" || ch === "{") depth++;
|
|
332
|
+
else if (ch === "]" || ch === "}") {
|
|
333
|
+
depth--;
|
|
334
|
+
if (depth <= 0) return depth === 0 ? i + 1 : -1;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return -1;
|
|
338
|
+
}
|
|
339
|
+
function parseJsonOrUndefined(text) {
|
|
340
|
+
try {
|
|
341
|
+
return JSON.parse(text);
|
|
342
|
+
} catch {
|
|
343
|
+
return void 0;
|
|
344
|
+
}
|
|
244
345
|
}
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
346
|
+
function readLoadBody(text) {
|
|
347
|
+
const body = text.trim();
|
|
348
|
+
if (body.startsWith("[")) {
|
|
349
|
+
const end = endOfFirstJsonValue(body);
|
|
350
|
+
if (end === -1) return void 0;
|
|
351
|
+
const warnings = parseJsonOrUndefined(body.slice(0, end));
|
|
352
|
+
if (!Array.isArray(warnings)) return void 0;
|
|
353
|
+
const tail = body.slice(end).trim();
|
|
354
|
+
if (!tail) return { warnings };
|
|
355
|
+
const trailing = parseJsonOrUndefined(tail);
|
|
356
|
+
if (trailing === null || typeof trailing !== "object" || Array.isArray(trailing)) return void 0;
|
|
357
|
+
if (typeof trailing.error !== "string") return void 0;
|
|
358
|
+
return { warnings, errorText: tail };
|
|
359
|
+
}
|
|
360
|
+
if (body.startsWith("{")) {
|
|
361
|
+
const parsed = parseJsonOrUndefined(body);
|
|
362
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
|
|
363
|
+
const warnings = parsed.warnings;
|
|
364
|
+
if (Array.isArray(warnings) && Object.keys(parsed).length === 1) return { warnings };
|
|
365
|
+
}
|
|
366
|
+
return void 0;
|
|
249
367
|
}
|
|
250
|
-
async function
|
|
368
|
+
async function attemptRequest(method, path, body, contentType, rawStringBody = false) {
|
|
369
|
+
const transport = { refused: false, timedOut: false };
|
|
370
|
+
const res = await sendOnce(transport, method, path, body, contentType, rawStringBody);
|
|
371
|
+
return { res, refused: transport.refused, timedOut: transport.timedOut };
|
|
372
|
+
}
|
|
373
|
+
async function sendOnce(transport, method, path, body, contentType, rawStringBody = false) {
|
|
251
374
|
const socketPath = getUnixSocketPath();
|
|
252
375
|
const url = `${getBaseUrl()}${path}`;
|
|
253
|
-
const effectiveTimeout =
|
|
376
|
+
const effectiveTimeout = getTimeoutFor(method, path);
|
|
254
377
|
try {
|
|
255
378
|
const hasBody = body !== void 0;
|
|
256
379
|
const headers = getHeaders(hasBody ? contentType || "application/json" : void 0, socketPath !== void 0);
|
|
@@ -262,11 +385,21 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
|
|
|
262
385
|
}
|
|
263
386
|
const serializedBody = hasBody ? rawStringBody && typeof body === "string" ? body : JSON.stringify(body) : void 0;
|
|
264
387
|
const res = socketPath ? await sendViaUnixSocket(socketPath, path, method, headers, serializedBody, effectiveTimeout) : await sendViaFetch(url, method, headers, serializedBody, effectiveTimeout);
|
|
388
|
+
const loadBody = path === "/load" && res.ok ? readLoadBody(res.text) : void 0;
|
|
389
|
+
if (loadBody?.errorText !== void 0) {
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
status: res.status,
|
|
393
|
+
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.`,
|
|
394
|
+
warnings: loadBody.warnings
|
|
395
|
+
};
|
|
396
|
+
}
|
|
265
397
|
if (!socketPath && res.ok && isConfigChange(method, path)) await settleAdminRestart(getAdminOrigin());
|
|
266
398
|
const text = res.text;
|
|
267
399
|
const etag = res.etag;
|
|
268
400
|
if (method === "GET" && etag && isConfigPath) {
|
|
269
|
-
setEtag(path, etag);
|
|
401
|
+
if (isEchoableEtag(etag)) setEtag(path, etag);
|
|
402
|
+
else etagCache.delete(path);
|
|
270
403
|
}
|
|
271
404
|
if (isWrite && res.ok && isConfigPath) {
|
|
272
405
|
invalidateRelated(path);
|
|
@@ -298,6 +431,7 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
|
|
|
298
431
|
return { ok: false, status: res.status, error: text };
|
|
299
432
|
}
|
|
300
433
|
if (!text) return { ok: true, status: res.status, etag };
|
|
434
|
+
if (loadBody) return { ok: true, status: res.status, warnings: loadBody.warnings, etag };
|
|
301
435
|
try {
|
|
302
436
|
return { ok: true, status: res.status, data: JSON.parse(text), etag };
|
|
303
437
|
} catch {
|
|
@@ -327,8 +461,16 @@ async function sendOnce(transport, method, path, body, contentType, timeout, raw
|
|
|
327
461
|
error: `Cannot connect to Caddy admin API at ${target} \u2014 is Caddy running?`
|
|
328
462
|
};
|
|
329
463
|
}
|
|
330
|
-
if (msg.includes("abort") || msg.includes("timeout")) {
|
|
331
|
-
|
|
464
|
+
if (isTimeoutError(err) || msg.includes("abort") || msg.includes("timeout") || msg.includes("timed out")) {
|
|
465
|
+
transport.timedOut = true;
|
|
466
|
+
const timedOutMsg = `Request timed out after ${effectiveTimeout}ms`;
|
|
467
|
+
if (!isConfigChange(method, path)) return { ok: false, status: 0, error: timedOutMsg };
|
|
468
|
+
return {
|
|
469
|
+
ok: false,
|
|
470
|
+
status: 0,
|
|
471
|
+
outcomeUnknown: true,
|
|
472
|
+
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.`
|
|
473
|
+
};
|
|
332
474
|
}
|
|
333
475
|
return { ok: false, status: 0, error: msg };
|
|
334
476
|
}
|
|
@@ -374,20 +516,23 @@ function getRequestTimeout() {
|
|
|
374
516
|
}
|
|
375
517
|
function getLoadTimeout() {
|
|
376
518
|
const raw = process.env.CADDY_LOAD_TIMEOUT;
|
|
377
|
-
if (raw === void 0) return
|
|
519
|
+
if (raw === void 0) return LOAD_TIMEOUT;
|
|
378
520
|
const n = Number(raw);
|
|
379
|
-
if (!Number.isFinite(n)) return
|
|
521
|
+
if (!Number.isFinite(n)) return LOAD_TIMEOUT;
|
|
380
522
|
const floored = Math.floor(n);
|
|
381
|
-
if (floored < 1) return
|
|
523
|
+
if (floored < 1) return LOAD_TIMEOUT;
|
|
382
524
|
return floored;
|
|
383
525
|
}
|
|
526
|
+
function getTimeoutFor(method, path) {
|
|
527
|
+
return isConfigChange(method, path) ? getLoadTimeout() : getRequestTimeout();
|
|
528
|
+
}
|
|
384
529
|
async function loadConfig(config, contentType) {
|
|
385
|
-
const res = await caddyRequest("POST", "/load", config, contentType,
|
|
530
|
+
const res = await caddyRequest("POST", "/load", config, contentType, true);
|
|
386
531
|
if (res.ok) etagCache.clear();
|
|
387
532
|
return res;
|
|
388
533
|
}
|
|
389
534
|
function adapt(config, adapter = "caddyfile") {
|
|
390
|
-
return caddyRequest("POST", "/adapt", config, `text/${adapter}`,
|
|
535
|
+
return caddyRequest("POST", "/adapt", config, `text/${adapter}`, true);
|
|
391
536
|
}
|
|
392
537
|
function stop() {
|
|
393
538
|
return caddyRequest("POST", "/stop");
|
|
@@ -441,28 +586,132 @@ function getMetrics() {
|
|
|
441
586
|
import { z } from "zod";
|
|
442
587
|
|
|
443
588
|
// src/format.ts
|
|
589
|
+
var WARNING_KEYS = /* @__PURE__ */ new Set(["file", "line", "directive", "message"]);
|
|
590
|
+
function formatWarning(w) {
|
|
591
|
+
const asJson = () => ` - ${JSON.stringify(w)}`;
|
|
592
|
+
if (w === null || typeof w !== "object" || Array.isArray(w)) return asJson();
|
|
593
|
+
const obj = w;
|
|
594
|
+
if (Object.keys(obj).some((key) => !WARNING_KEYS.has(key))) return asJson();
|
|
595
|
+
const { file, line, directive, message } = obj;
|
|
596
|
+
if (typeof message !== "string" || message === "") return asJson();
|
|
597
|
+
if (file !== void 0 && typeof file !== "string") return asJson();
|
|
598
|
+
if (line !== void 0 && typeof line !== "number") return asJson();
|
|
599
|
+
if (directive !== void 0 && typeof directive !== "string") return asJson();
|
|
600
|
+
const where = file && line !== void 0 ? `${file}:${line}` : file || (line !== void 0 ? `line ${line}` : "");
|
|
601
|
+
const prefix = [where, directive ? `(${directive})` : ""].filter(Boolean).join(" ");
|
|
602
|
+
return ` - ${prefix ? `${prefix}: ` : ""}${message}`;
|
|
603
|
+
}
|
|
604
|
+
function formatWarnings(warnings) {
|
|
605
|
+
if (!warnings || warnings.length === 0) return "";
|
|
606
|
+
return `
|
|
607
|
+
|
|
608
|
+
Adapter warnings (${warnings.length}):
|
|
609
|
+
${warnings.map(formatWarning).join("\n")}`;
|
|
610
|
+
}
|
|
444
611
|
function formatResult(res) {
|
|
612
|
+
const warnings = formatWarnings(res.warnings);
|
|
445
613
|
if (!res.ok) {
|
|
446
614
|
return {
|
|
447
615
|
isError: true,
|
|
448
|
-
content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}` }]
|
|
616
|
+
content: [{ type: "text", text: `Error: ${res.error || `HTTP ${res.status}`}${warnings}` }]
|
|
449
617
|
};
|
|
450
618
|
}
|
|
451
619
|
const raw = res.data !== void 0 ? typeof res.data === "string" ? res.data : JSON.stringify(res.data, null, 2) : "";
|
|
452
620
|
const text = raw || "OK";
|
|
453
|
-
return { content: [{ type: "text", text }] };
|
|
621
|
+
return { content: [{ type: "text", text: `${text}${warnings}` }] };
|
|
454
622
|
}
|
|
455
623
|
|
|
456
624
|
// src/tools/operational.ts
|
|
457
|
-
var
|
|
458
|
-
|
|
625
|
+
var DEFAULT_HTTP_PORT = 80;
|
|
626
|
+
var DEFAULT_HTTPS_PORT = 443;
|
|
627
|
+
function appPort(value, fallback) {
|
|
628
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535 ? value : fallback;
|
|
629
|
+
}
|
|
630
|
+
function splitPort(hostport) {
|
|
631
|
+
const i = hostport.lastIndexOf(":");
|
|
632
|
+
if (i < 0) return void 0;
|
|
633
|
+
if (hostport.startsWith("[")) {
|
|
634
|
+
const end = hostport.indexOf("]");
|
|
635
|
+
if (end < 0 || end + 1 !== i) return void 0;
|
|
636
|
+
if (hostport.slice(1).includes("[") || hostport.slice(end + 1).includes("]")) return void 0;
|
|
637
|
+
} else {
|
|
638
|
+
if (hostport.slice(0, i).includes(":")) return void 0;
|
|
639
|
+
if (hostport.includes("[") || hostport.includes("]")) return void 0;
|
|
640
|
+
}
|
|
641
|
+
return hostport.slice(i + 1);
|
|
642
|
+
}
|
|
643
|
+
function parseListenPortRange(entry) {
|
|
644
|
+
let rest = entry;
|
|
645
|
+
const slash = entry.indexOf("/");
|
|
646
|
+
if (slash >= 0) {
|
|
647
|
+
const network = entry.slice(0, slash).trim().toLowerCase();
|
|
648
|
+
rest = entry.slice(slash + 1);
|
|
649
|
+
if (network.startsWith("unix") || network.startsWith("fd")) return { start: 0, end: 0 };
|
|
650
|
+
}
|
|
651
|
+
const port = splitPort(rest);
|
|
652
|
+
if (port === void 0 || port === "") return { start: 0, end: 0 };
|
|
653
|
+
const dash = port.indexOf("-");
|
|
654
|
+
const start = parseUint16(dash < 0 ? port : port.slice(0, dash));
|
|
655
|
+
const end = parseUint16(dash < 0 ? port : port.slice(dash + 1));
|
|
656
|
+
if (start === void 0 || end === void 0 || end < start) return void 0;
|
|
657
|
+
return { start, end };
|
|
658
|
+
}
|
|
659
|
+
function parseUint16(s) {
|
|
660
|
+
if (!/^[0-9]+$/.test(s)) return void 0;
|
|
661
|
+
const n = Number(s);
|
|
662
|
+
return n <= 65535 ? n : void 0;
|
|
663
|
+
}
|
|
664
|
+
function hasQualifyingHost(routes, skip) {
|
|
665
|
+
for (const route of routes) {
|
|
666
|
+
if (!route || typeof route !== "object") continue;
|
|
667
|
+
const matcherSets = route.match;
|
|
668
|
+
if (!Array.isArray(matcherSets)) continue;
|
|
669
|
+
for (const set of matcherSets) {
|
|
670
|
+
if (!set || typeof set !== "object") continue;
|
|
671
|
+
const hosts = set.host;
|
|
672
|
+
if (!Array.isArray(hosts)) continue;
|
|
673
|
+
for (const host of hosts) {
|
|
674
|
+
if (typeof host === "string" && !skip.has(host)) return true;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
function describeServer(rawValue, httpPort = DEFAULT_HTTP_PORT, httpsPort = DEFAULT_HTTPS_PORT) {
|
|
459
681
|
const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
|
460
682
|
const listen = Array.isArray(raw.listen) ? raw.listen : [];
|
|
461
683
|
const routes = Array.isArray(raw.routes) ? raw.routes : [];
|
|
684
|
+
const ranges = [];
|
|
685
|
+
for (const entry of listen) {
|
|
686
|
+
if (typeof entry !== "string") continue;
|
|
687
|
+
const range = parseListenPortRange(entry);
|
|
688
|
+
if (range) ranges.push(range);
|
|
689
|
+
}
|
|
690
|
+
const usesAnyPortOtherThan = (port) => ranges.some((r) => port > r.end || port < r.start);
|
|
691
|
+
const bindsPort = (port) => ranges.some((r) => r.start <= port && port <= r.end);
|
|
692
|
+
const autoHttps = raw.automatic_https !== null && typeof raw.automatic_https === "object" && !Array.isArray(raw.automatic_https) ? raw.automatic_https : {};
|
|
693
|
+
const skip = new Set(
|
|
694
|
+
Array.isArray(autoHttps.skip) ? autoHttps.skip.filter((s) => typeof s === "string") : []
|
|
695
|
+
);
|
|
696
|
+
const allSocketsOnHttpPort = (port) => ranges.length > 0 && ranges.every((r) => r.start === port && r.end === port);
|
|
697
|
+
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;
|
|
462
698
|
const tlsPolicies = raw.tls_connection_policies;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
699
|
+
let tls;
|
|
700
|
+
if (Array.isArray(tlsPolicies) && tlsPolicies.length === 0) {
|
|
701
|
+
tls = "off (empty tls_connection_policies)";
|
|
702
|
+
} else if (tlsPolicies) {
|
|
703
|
+
tls = perListener("enabled");
|
|
704
|
+
} else if (autoHttps.disable === true) {
|
|
705
|
+
tls = "off (automatic HTTPS disabled)";
|
|
706
|
+
} else if (!usesAnyPortOtherThan(httpPort)) {
|
|
707
|
+
tls = "off (HTTP only)";
|
|
708
|
+
} else if (!usesAnyPortOtherThan(httpsPort)) {
|
|
709
|
+
tls = perListener("auto (HTTPS)");
|
|
710
|
+
} else if (hasQualifyingHost(routes, skip)) {
|
|
711
|
+
tls = perListener("auto (HTTPS: host matchers on a non-HTTP port)");
|
|
712
|
+
} else {
|
|
713
|
+
tls = "off (no host matchers)";
|
|
714
|
+
}
|
|
466
715
|
const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
|
|
467
716
|
return `${routes.length} route(s), listen: ${listenStr}, TLS: ${tls}`;
|
|
468
717
|
}
|
|
@@ -521,14 +770,17 @@ function registerOperationalTools(server) {
|
|
|
521
770
|
const res = await configGet();
|
|
522
771
|
if (!res.ok) return formatResult(res);
|
|
523
772
|
const config = res.data ?? {};
|
|
524
|
-
const
|
|
773
|
+
const httpApp = config.apps?.http;
|
|
774
|
+
const servers = httpApp?.servers ?? {};
|
|
525
775
|
const serverNames = Object.keys(servers);
|
|
776
|
+
const httpPort = appPort(httpApp?.http_port, DEFAULT_HTTP_PORT);
|
|
777
|
+
const httpsPort = appPort(httpApp?.https_port, DEFAULT_HTTPS_PORT);
|
|
526
778
|
const lines = ["Caddy is running", ""];
|
|
527
779
|
if (serverNames.length === 0) {
|
|
528
780
|
lines.push("No HTTP servers configured");
|
|
529
781
|
} else {
|
|
530
782
|
for (const name of serverNames) {
|
|
531
|
-
lines.push(`Server "${name}": ${describeServer(servers[name])}`);
|
|
783
|
+
lines.push(`Server "${name}": ${describeServer(servers[name], httpPort, httpsPort)}`);
|
|
532
784
|
}
|
|
533
785
|
}
|
|
534
786
|
const email = findAcmeEmail(config.apps?.tls?.automation?.policies);
|
|
@@ -562,7 +814,7 @@ ${lines.join("\n")}` }]
|
|
|
562
814
|
);
|
|
563
815
|
server.tool(
|
|
564
816
|
"caddy_upstreams",
|
|
565
|
-
"
|
|
817
|
+
"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.",
|
|
566
818
|
{},
|
|
567
819
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
568
820
|
async () => formatResult(await getUpstreams())
|
|
@@ -636,7 +888,9 @@ function registerResources(server) {
|
|
|
636
888
|
server.resource(
|
|
637
889
|
"caddy-upstreams",
|
|
638
890
|
"caddy://upstreams",
|
|
639
|
-
{
|
|
891
|
+
{
|
|
892
|
+
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."
|
|
893
|
+
},
|
|
640
894
|
async () => {
|
|
641
895
|
const res = await getUpstreams();
|
|
642
896
|
return {
|
|
@@ -702,7 +956,7 @@ function registerResources(server) {
|
|
|
702
956
|
|
|
703
957
|
// src/tools/adapt.ts
|
|
704
958
|
import { z as z2 } from "zod";
|
|
705
|
-
function
|
|
959
|
+
function formatWarning2(w) {
|
|
706
960
|
if (!w || typeof w !== "object") return ` - unknown: ${JSON.stringify(w)}`;
|
|
707
961
|
const obj = w;
|
|
708
962
|
const directive = typeof obj.directive === "string" ? obj.directive : "unknown";
|
|
@@ -712,7 +966,7 @@ function formatWarning(w) {
|
|
|
712
966
|
function registerAdaptTools(server) {
|
|
713
967
|
server.tool(
|
|
714
968
|
"caddy_adapt",
|
|
715
|
-
|
|
969
|
+
`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.`,
|
|
716
970
|
{
|
|
717
971
|
config: z2.string().describe("The raw config text (e.g., Caddyfile contents, nginx.conf, yaml)"),
|
|
718
972
|
adapter: z2.string().regex(/^[a-z0-9_-]+$/, "Adapter must be lowercase alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe(
|
|
@@ -728,7 +982,7 @@ function registerAdaptTools(server) {
|
|
|
728
982
|
const result = data.result;
|
|
729
983
|
const content = [];
|
|
730
984
|
if (warnings.length > 0) {
|
|
731
|
-
const warnLines = warnings.map(
|
|
985
|
+
const warnLines = warnings.map(formatWarning2);
|
|
732
986
|
content.push({ type: "text", text: `Warnings:
|
|
733
987
|
${warnLines.join("\n")}` });
|
|
734
988
|
}
|
|
@@ -822,6 +1076,9 @@ function getSnapshot(index) {
|
|
|
822
1076
|
}
|
|
823
1077
|
|
|
824
1078
|
// src/tools/config.ts
|
|
1079
|
+
function isRootConfigPath(path) {
|
|
1080
|
+
return /^\/*$/.test(path.replace(/^\/?(config(\/|$))?/, ""));
|
|
1081
|
+
}
|
|
825
1082
|
function registerConfigTools(server) {
|
|
826
1083
|
server.tool(
|
|
827
1084
|
"caddy_config_get",
|
|
@@ -832,12 +1089,12 @@ function registerConfigTools(server) {
|
|
|
832
1089
|
);
|
|
833
1090
|
server.tool(
|
|
834
1091
|
"caddy_config_set",
|
|
835
|
-
"Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. Mode 'append' adds to
|
|
1092
|
+
"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.",
|
|
836
1093
|
{
|
|
837
1094
|
path: z3.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
|
|
838
1095
|
value: z3.any().describe("The JSON value to set at the path"),
|
|
839
1096
|
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
840
|
-
"'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (
|
|
1097
|
+
"'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)"
|
|
841
1098
|
)
|
|
842
1099
|
},
|
|
843
1100
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
@@ -848,7 +1105,7 @@ function registerConfigTools(server) {
|
|
|
848
1105
|
);
|
|
849
1106
|
server.tool(
|
|
850
1107
|
"caddy_config_delete",
|
|
851
|
-
"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.",
|
|
1108
|
+
"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.",
|
|
852
1109
|
{
|
|
853
1110
|
path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
|
|
854
1111
|
confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
|
|
@@ -859,27 +1116,54 @@ function registerConfigTools(server) {
|
|
|
859
1116
|
// array after a delete, so repeating that call removes a DIFFERENT route each
|
|
860
1117
|
// time. caddy_remove_route carries the same correction for the byte-identical
|
|
861
1118
|
// underlying request; the two must agree. Nothing here is auto-recoverable
|
|
862
|
-
// either:
|
|
863
|
-
// undone with caddy_revert.
|
|
1119
|
+
// either: apart from a root delete (below), only caddy_load captures a
|
|
1120
|
+
// snapshot, so a spurious repeat cannot be undone with caddy_revert.
|
|
864
1121
|
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
865
1122
|
async ({ path, confirm }) => {
|
|
1123
|
+
const root = isRootConfigPath(path);
|
|
866
1124
|
if (!confirm) {
|
|
867
1125
|
return {
|
|
868
1126
|
isError: true,
|
|
869
1127
|
content: [
|
|
870
1128
|
{
|
|
871
1129
|
type: "text",
|
|
872
|
-
text: `Refusing to delete "${path}" without confirm=true. Deleting a parent path also removes all descendants. Re-run with confirm:true to proceed.`
|
|
1130
|
+
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.`
|
|
873
1131
|
}
|
|
874
1132
|
]
|
|
875
1133
|
};
|
|
876
1134
|
}
|
|
877
|
-
return formatResult(await configDelete(path));
|
|
1135
|
+
if (!root) return formatResult(await configDelete(path));
|
|
1136
|
+
const current = await configGet();
|
|
1137
|
+
const res = await configDelete("");
|
|
1138
|
+
const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
|
|
1139
|
+
if (kept) saveSnapshot(current.data, "caddy_config_delete");
|
|
1140
|
+
if (!res.ok) {
|
|
1141
|
+
if (res.outcomeUnknown && kept) {
|
|
1142
|
+
return formatResult({
|
|
1143
|
+
...res,
|
|
1144
|
+
error: `${res.error}
|
|
1145
|
+
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.`
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
return formatResult(res);
|
|
1149
|
+
}
|
|
1150
|
+
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.";
|
|
1151
|
+
const adm = kept ? current.data.admin : void 0;
|
|
1152
|
+
const listen = adm !== null && typeof adm === "object" && !Array.isArray(adm) ? adm.listen : void 0;
|
|
1153
|
+
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.` : "";
|
|
1154
|
+
return {
|
|
1155
|
+
content: [
|
|
1156
|
+
{
|
|
1157
|
+
type: "text",
|
|
1158
|
+
text: `Unloaded the entire config (every app and server, and the 'admin' block).${note}${adminNote}`
|
|
1159
|
+
}
|
|
1160
|
+
]
|
|
1161
|
+
};
|
|
878
1162
|
}
|
|
879
1163
|
);
|
|
880
1164
|
server.tool(
|
|
881
1165
|
"caddy_load",
|
|
882
|
-
"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.
|
|
1166
|
+
"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.",
|
|
883
1167
|
{
|
|
884
1168
|
config: z3.union([z3.record(z3.string(), z3.any()), z3.string()]).describe("Full config \u2014 JSON object or Caddyfile text string"),
|
|
885
1169
|
format: z3.enum(["json", "caddyfile"]).optional().default("json").describe("Config format: 'json' (default) or 'caddyfile'"),
|
|
@@ -901,15 +1185,21 @@ function registerConfigTools(server) {
|
|
|
901
1185
|
const contentType = format === "caddyfile" ? "text/caddyfile" : "application/json";
|
|
902
1186
|
const current = await configGet();
|
|
903
1187
|
const res = await loadConfig(config, contentType);
|
|
904
|
-
|
|
905
|
-
|
|
1188
|
+
const kept = (res.ok || res.outcomeUnknown === true) && current.ok && isSnapshotableConfig(current.data);
|
|
1189
|
+
if (kept) saveSnapshot(current.data, "caddy_load");
|
|
1190
|
+
if (res.outcomeUnknown && kept) {
|
|
1191
|
+
return formatResult({
|
|
1192
|
+
...res,
|
|
1193
|
+
error: `${res.error}
|
|
1194
|
+
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.`
|
|
1195
|
+
});
|
|
906
1196
|
}
|
|
907
1197
|
return formatResult(res);
|
|
908
1198
|
}
|
|
909
1199
|
);
|
|
910
1200
|
server.tool(
|
|
911
1201
|
"caddy_revert",
|
|
912
|
-
"Manage config snapshots for rollback. Snapshots are auto-captured before caddy_load (
|
|
1202
|
+
"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).",
|
|
913
1203
|
{
|
|
914
1204
|
action: z3.enum(["list", "save", "apply"]).describe("Action to perform"),
|
|
915
1205
|
index: z3.number().int().nonnegative().optional().default(0).describe("Snapshot index for 'apply' (0 = most recent, default)"),
|
|
@@ -972,7 +1262,19 @@ ${lines.join("\n")}` }] };
|
|
|
972
1262
|
}
|
|
973
1263
|
const current = await configGet();
|
|
974
1264
|
const res = await loadConfig(snap.config, "application/json");
|
|
975
|
-
if (!res.ok)
|
|
1265
|
+
if (!res.ok) {
|
|
1266
|
+
if (res.outcomeUnknown && current.ok && isSnapshotableConfig(current.data)) {
|
|
1267
|
+
saveSnapshot(current.data, "caddy_revert");
|
|
1268
|
+
const now = listSnapshots().indexOf(snap);
|
|
1269
|
+
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}]`;
|
|
1270
|
+
return formatResult({
|
|
1271
|
+
...res,
|
|
1272
|
+
error: `${res.error}
|
|
1273
|
+
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.`
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
return formatResult(res);
|
|
1277
|
+
}
|
|
976
1278
|
const capturedRollforward = current.ok && isSnapshotableConfig(current.data);
|
|
977
1279
|
if (capturedRollforward) {
|
|
978
1280
|
saveSnapshot(current.data, "caddy_revert");
|
|
@@ -996,7 +1298,7 @@ ${lines.join("\n")}` }] };
|
|
|
996
1298
|
value: z3.any().optional().describe("New value (required for 'set' action)"),
|
|
997
1299
|
subpath: z3.string().optional().default("").describe("Optional sub-path within the identified object"),
|
|
998
1300
|
mode: z3.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
999
|
-
"For 'set' action: 'overwrite' = PATCH (replace
|
|
1301
|
+
"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."
|
|
1000
1302
|
),
|
|
1001
1303
|
confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
|
|
1002
1304
|
},
|
|
@@ -1152,7 +1454,7 @@ function serverNotFoundError(srv, op = "operation") {
|
|
|
1152
1454
|
content: [
|
|
1153
1455
|
{
|
|
1154
1456
|
type: "text",
|
|
1155
|
-
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: "
|
|
1457
|
+
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.`
|
|
1156
1458
|
}
|
|
1157
1459
|
]
|
|
1158
1460
|
};
|
|
@@ -1163,7 +1465,7 @@ function serverNullError(srv) {
|
|
|
1163
1465
|
content: [
|
|
1164
1466
|
{
|
|
1165
1467
|
type: "text",
|
|
1166
|
-
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
|
|
1468
|
+
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.`
|
|
1167
1469
|
}
|
|
1168
1470
|
]
|
|
1169
1471
|
};
|
|
@@ -1546,7 +1848,7 @@ function buildTlsConfig(fields) {
|
|
|
1546
1848
|
}
|
|
1547
1849
|
};
|
|
1548
1850
|
}
|
|
1549
|
-
function bothErrors(label, patchRes, writeRes, writeLabel) {
|
|
1851
|
+
function bothErrors(label, patchRes, writeRes, writeLabel, hint) {
|
|
1550
1852
|
const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
|
|
1551
1853
|
const writeErr = writeRes.error || `HTTP ${writeRes.status}`;
|
|
1552
1854
|
return {
|
|
@@ -1556,11 +1858,16 @@ function bothErrors(label, patchRes, writeRes, writeLabel) {
|
|
|
1556
1858
|
type: "text",
|
|
1557
1859
|
text: `Error: Failed to set ${label}.
|
|
1558
1860
|
PATCH attempt: ${patchErr}
|
|
1559
|
-
${writeLabel} fallback: ${writeErr}`
|
|
1861
|
+
${writeLabel} fallback: ${writeErr}` + (hint ? `
|
|
1862
|
+
${hint}` : "")
|
|
1560
1863
|
}
|
|
1561
1864
|
]
|
|
1562
1865
|
};
|
|
1563
1866
|
}
|
|
1867
|
+
function isAbsentOnGet(res) {
|
|
1868
|
+
if (res.ok) return res.data === void 0 || res.data === null;
|
|
1869
|
+
return isMissingConfigPath(res);
|
|
1870
|
+
}
|
|
1564
1871
|
function isPlainObject(v) {
|
|
1565
1872
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1566
1873
|
}
|
|
@@ -1623,13 +1930,15 @@ function refuseFallback(label, patchRes, detail) {
|
|
|
1623
1930
|
}
|
|
1624
1931
|
};
|
|
1625
1932
|
}
|
|
1933
|
+
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.";
|
|
1626
1934
|
async function safeFallback(label, patchRes, fields) {
|
|
1627
1935
|
const getRes = await configGet("apps/tls");
|
|
1628
|
-
const absent =
|
|
1936
|
+
const absent = isAbsentOnGet(getRes);
|
|
1629
1937
|
if (absent) {
|
|
1630
|
-
const
|
|
1631
|
-
if (
|
|
1632
|
-
|
|
1938
|
+
const putRes = await configPut("apps/tls", buildTlsConfig(fields));
|
|
1939
|
+
if (putRes.ok) return { kind: "ok" };
|
|
1940
|
+
const hint = putRes.status === 409 ? CREATE_CONFLICT_HINT : void 0;
|
|
1941
|
+
return { kind: "tool-error", result: bothErrors(label, patchRes, putRes, "PUT", hint) };
|
|
1633
1942
|
}
|
|
1634
1943
|
if (!getRes.ok) {
|
|
1635
1944
|
return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
|
|
@@ -1671,29 +1980,39 @@ function missingArgError(text) {
|
|
|
1671
1980
|
function registerTlsTools(server) {
|
|
1672
1981
|
server.tool(
|
|
1673
1982
|
"caddy_tls",
|
|
1674
|
-
"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.",
|
|
1983
|
+
"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.",
|
|
1675
1984
|
{
|
|
1676
1985
|
action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
|
|
1677
1986
|
email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
|
|
1678
1987
|
ca: z5.string().optional().describe("ACME CA URL (for 'set_acme_ca' action)"),
|
|
1679
1988
|
profile: z5.string().optional().describe(
|
|
1680
|
-
"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."
|
|
1989
|
+
"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."
|
|
1681
1990
|
)
|
|
1682
1991
|
},
|
|
1683
1992
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1684
1993
|
async ({ action, email, ca, profile }) => {
|
|
1685
1994
|
if (action === "status") {
|
|
1686
|
-
|
|
1995
|
+
const tlsRes = await configGet("apps/tls");
|
|
1996
|
+
if (isAbsentOnGet(tlsRes)) {
|
|
1997
|
+
return {
|
|
1998
|
+
content: [
|
|
1999
|
+
{
|
|
2000
|
+
type: "text",
|
|
2001
|
+
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."
|
|
2002
|
+
}
|
|
2003
|
+
]
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
return formatResult(tlsRes);
|
|
1687
2007
|
}
|
|
1688
2008
|
if (action === "ech_status") {
|
|
1689
|
-
const echRes = await configGet("apps/tls/
|
|
1690
|
-
|
|
1691
|
-
if (absent) {
|
|
2009
|
+
const echRes = await configGet("apps/tls/encrypted_client_hello");
|
|
2010
|
+
if (isAbsentOnGet(echRes)) {
|
|
1692
2011
|
return {
|
|
1693
2012
|
content: [
|
|
1694
2013
|
{
|
|
1695
2014
|
type: "text",
|
|
1696
|
-
text: "ECH (Encrypted ClientHello) is not configured on this instance. Requires Caddy 2.10+; enable it by applying a config
|
|
2015
|
+
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."
|
|
1697
2016
|
}
|
|
1698
2017
|
]
|
|
1699
2018
|
};
|