@yawlabs/caddy-mcp 2.3.2 → 2.4.1
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 +1 -0
- package/bin/caddy-mcp.mjs +132 -17
- package/dist/api.d.ts +19 -0
- package/dist/index.js +187 -63
- package/dist/server.js +187 -63
- package/package.json +18 -7
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://github.com/YawLabs/caddy-mcp/stargazers)
|
|
6
|
+
[](https://x.com/TokenLimitNews)
|
|
6
7
|
|
|
7
8
|
**Manage Caddy web servers from Claude Code, Cursor, and any MCP client.** 18 tools + 4 resources covering every endpoint of Caddy's admin API — config, routes, reverse proxies, TLS, PKI, metrics, snapshots.
|
|
8
9
|
|
package/bin/caddy-mcp.mjs
CHANGED
|
@@ -32,11 +32,25 @@
|
|
|
32
32
|
* `CADDY_MCP_SANDBOX=1` runs the server under oam's permission model.
|
|
33
33
|
*
|
|
34
34
|
* The admin API endpoint is DERIVED from CADDY_ADMIN_URL (default
|
|
35
|
-
* http://
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* http://localhost:2019 -- byte-identical to DEFAULT_URL in src/api.ts, see
|
|
36
|
+
* sandboxFlags). For a TCP endpoint the grant is the HOST, deliberately WITHOUT
|
|
37
|
+
* a port: oam checks `fetch` against the bare hostname and sockets against
|
|
38
|
+
* "host:port", and grants are prefix-matched, so pinning the port denies every
|
|
39
|
+
* fetch -- and fetch is the transport api.ts uses for everything but a unix
|
|
40
|
+
* socket. Granting the host therefore also admits its other ports; that is the
|
|
41
|
+
* cost of the check having no port to match against.
|
|
42
|
+
*
|
|
43
|
+
* A unix-socket CADDY_ADMIN_URL gets NO net grant, which DENIES the category
|
|
44
|
+
* outright -- it is not an oversight that it looks narrower than the TCP case.
|
|
45
|
+
* oam ships no unix socket transport, so the socket dial cannot work under the
|
|
46
|
+
* sandbox regardless; the alternative was a bare `--allow-net`, which grants
|
|
47
|
+
* every host on the network. See sandboxFlags for the mechanism.
|
|
48
|
+
*
|
|
49
|
+
* Child-process stays denied: this server drives Caddy entirely over its admin
|
|
50
|
+
* HTTP API and never shells out to the `caddy` binary (the only execFileSync
|
|
51
|
+
* calls in the repo are in src/tests/). Filesystem stays denied too, EXCEPT when
|
|
52
|
+
* CADDY_MCP_SNAPSHOT_DIR is set: snapshot persistence is the one feature that
|
|
53
|
+
* touches disk, so that directory -- and nothing else -- is granted read+write.
|
|
40
54
|
*
|
|
41
55
|
* Opt-in, not default: a denied environment variable is ABSENT from process.env
|
|
42
56
|
* rather than throwing, so an under-granted CADDY_API_TOKEN reads as
|
|
@@ -111,6 +125,14 @@ function findOam() {
|
|
|
111
125
|
// the full PATHEXT list would hand back a path this launcher cannot execute.
|
|
112
126
|
// Discovery has to agree with execution. A skipped shim is still reported --
|
|
113
127
|
// see findOamShim.
|
|
128
|
+
//
|
|
129
|
+
// scripts/runtime.mjs carries its OWN findOam that DOES walk the full PATHEXT.
|
|
130
|
+
// That is a deliberate difference, not drift: it probes each candidate with
|
|
131
|
+
// execFileSync before returning it, so a shim it cannot run is dropped anyway
|
|
132
|
+
// and a wider walk only ever finds more. This one is stat-only on the hot
|
|
133
|
+
// launch path -- no probe to filter with -- so whatever it returns it must be
|
|
134
|
+
// able to spawn. Same question, different constraint; change one and re-read
|
|
135
|
+
// the other.
|
|
114
136
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
115
137
|
if (!dir) continue;
|
|
116
138
|
const candidate = join(dir, exe);
|
|
@@ -164,25 +186,118 @@ function sandboxFlags() {
|
|
|
164
186
|
if (process.env.CADDY_MCP_SANDBOX !== "1") return [];
|
|
165
187
|
|
|
166
188
|
// Derived, not hardcoded: the only endpoint this server may reach is the one
|
|
167
|
-
// it was configured to reach.
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
189
|
+
// it was configured to reach. A DSN we cannot parse falls back to a bare grant
|
|
190
|
+
// rather than a broken one, because a wrong narrow grant fails at connect time.
|
|
191
|
+
//
|
|
192
|
+
// The default MUST stay byte-identical to DEFAULT_URL in src/api.ts. The grant
|
|
193
|
+
// and the dial are matched as TEXT, so "127.0.0.1" here against an api.ts that
|
|
194
|
+
// dials "localhost" denies every request while both files look right on their
|
|
195
|
+
// own. Change one, change the other.
|
|
196
|
+
//
|
|
197
|
+
// Empty and whitespace-only are treated as UNSET, which is what api.ts does:
|
|
198
|
+
// it reads the variable with `||`, so "" already falls through to DEFAULT_URL
|
|
199
|
+
// there. `??` would keep "" here, skip the parse, and leave the bare
|
|
200
|
+
// `--allow-net` below -- a wide-open sandbox produced by a shell exporting an
|
|
201
|
+
// empty variable, which is a shape shells produce easily.
|
|
202
|
+
const dsn = process.env.CADDY_ADMIN_URL?.trim() || "http://localhost:2019";
|
|
203
|
+
|
|
204
|
+
// A unix-socket admin endpoint gets NO net grant at all -- checked before the
|
|
205
|
+
// URL parse, because it is the one input that would otherwise produce the
|
|
206
|
+
// WIDEST grant instead of the narrowest.
|
|
207
|
+
//
|
|
208
|
+
// `new URL("unix:///run/caddy.sock").hostname` is "", so the `if (u.hostname)`
|
|
209
|
+
// below is false and netFlag would stay the bare `--allow-net`; Caddy's own
|
|
210
|
+
// spelling (`unix//run/caddy.sock`) throws ERR_INVALID_URL and reaches the
|
|
211
|
+
// catch for the same result. Either way the most hardened admin config --
|
|
212
|
+
// Caddy recommends the socket precisely because filesystem permissions beat a
|
|
213
|
+
// loopback port -- would switch the sandbox on and hand over the whole network.
|
|
214
|
+
// That is the same wide-open-by-accident shape the empty-string case above
|
|
215
|
+
// guards against, reached by a different route.
|
|
216
|
+
//
|
|
217
|
+
// Omitting the flag DENIES the category (oam reads an absent --allow-net as
|
|
218
|
+
// false, a bare one as "*"), and denial costs nothing here: oam has no unix
|
|
219
|
+
// socket transport at all, so api.ts's node:http `socketPath` dial cannot work
|
|
220
|
+
// under oam whether the grant is open or closed. Verified against oam 0.9.0 --
|
|
221
|
+
// bare grant lets an unrelated host through, omitted grant denies it.
|
|
222
|
+
//
|
|
223
|
+
// This mirrors getMalformedUnixUrl's predicate in src/api.ts, NOT the stricter
|
|
224
|
+
// getUnixSocketPath -- deliberately, and the difference is the whole point.
|
|
225
|
+
//
|
|
226
|
+
// getUnixSocketPath accepts only the two WELL-FORMED spellings; matching it
|
|
227
|
+
// here would leave the malformed ones ("unix:/one-slash", "unix://relative",
|
|
228
|
+
// "unix://", or any uppercase spelling, which getUnixSocketPath rejects for
|
|
229
|
+
// case) falling through to the parse, where they yield an empty hostname and
|
|
230
|
+
// the bare `--allow-net` -- fully open, for input that plainly meant a socket.
|
|
231
|
+
//
|
|
232
|
+
// Denying the category for those costs nothing: api.ts routes exactly this set
|
|
233
|
+
// to getMalformedUnixUrl, which fails the request up front with a message
|
|
234
|
+
// naming the spelling error, so no request is ever attempted. Matching the
|
|
235
|
+
// broad predicate is what makes the header's claim true for EVERY unix-ish
|
|
236
|
+
// input rather than just the two tidy ones.
|
|
237
|
+
//
|
|
238
|
+
// `[:/]` after "unix" rather than a bare "unix" prefix, so a real TCP host like
|
|
239
|
+
// "http://unix.example.com:2019" is not swept up -- the same care api.ts takes.
|
|
240
|
+
const isUnixDsn = /^unix[:/]/i.test(dsn);
|
|
241
|
+
|
|
242
|
+
let netFlag = isUnixDsn ? null : "--allow-net";
|
|
243
|
+
if (!isUnixDsn) {
|
|
174
244
|
try {
|
|
175
245
|
const u = new URL(dsn);
|
|
176
|
-
|
|
246
|
+
// HOST ONLY, no port, deliberately. Grants are prefix-matched against the
|
|
247
|
+
// resource string, and the resource `fetch` presents is the bare hostname
|
|
248
|
+
// ("localhost") while sockets present "host:port". "localhost" does not
|
|
249
|
+
// start with "localhost:2019", so pinning the port denies every fetch --
|
|
250
|
+
// and fetch is how api.ts talks to a TCP admin endpoint. Granting the host
|
|
251
|
+
// alone also admits the other ports on that host; that is the cost of the
|
|
252
|
+
// check having no port to match against, not an oversight here.
|
|
253
|
+
if (u.hostname) netFlag = `--allow-net=${u.hostname}`;
|
|
177
254
|
} catch {
|
|
178
|
-
//
|
|
179
|
-
//
|
|
255
|
+
// Genuinely unparseable CADDY_ADMIN_URL (not the unix forms -- those are
|
|
256
|
+
// handled above): leave the grant open. The server will fail on its own
|
|
257
|
+
// connection error, which names the real problem.
|
|
180
258
|
}
|
|
181
259
|
}
|
|
182
260
|
|
|
183
|
-
|
|
261
|
+
// Every variable the shipped bundle reads (`grep process.env src/`), including
|
|
262
|
+
// CADDY_MCP_SNAPSHOT_DIR in src/snapshots.ts. Omitting one is not a denial the
|
|
263
|
+
// operator can see: the variable is simply ABSENT, so the feature reads as
|
|
264
|
+
// "not configured" and degrades silently.
|
|
265
|
+
const env = [
|
|
266
|
+
"CADDY_ADMIN_URL",
|
|
267
|
+
"CADDY_API_TOKEN",
|
|
268
|
+
"CADDY_LOAD_TIMEOUT",
|
|
269
|
+
"CADDY_MAX_RETRIES",
|
|
270
|
+
"CADDY_MCP_SNAPSHOT_DIR",
|
|
271
|
+
"CADDY_TIMEOUT",
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
// netFlag is null for a unix DSN -- an OMITTED --allow-net is what denies the
|
|
275
|
+
// category, so it must not survive as a stray "null" argv entry.
|
|
276
|
+
const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`].filter(Boolean);
|
|
277
|
+
|
|
278
|
+
// The filesystem grant exists only when snapshot persistence is switched on,
|
|
279
|
+
// and only for the directory it points at. Granting the variable without the
|
|
280
|
+
// directory would just move the silent failure: src/snapshots.ts swallows its
|
|
281
|
+
// own I/O errors and degrades to the in-memory ring, so `caddy_revert` would
|
|
282
|
+
// quietly stop surviving a restart -- the thing the operator turned the
|
|
283
|
+
// variable on to get.
|
|
284
|
+
//
|
|
285
|
+
// TWO spellings, because grants are matched as plain string PREFIXES against
|
|
286
|
+
// whatever path each call passes: snapshots.ts hands the raw variable to
|
|
287
|
+
// readdirSync/mkdirSync but builds per-file paths with path.join, which
|
|
288
|
+
// normalizes ("./snaps" -> "snaps"). The raw form alone then misses the files;
|
|
289
|
+
// the normalized form alone misses the directory listing.
|
|
290
|
+
//
|
|
291
|
+
// Two consequences worth naming rather than discovering: a prefix also admits
|
|
292
|
+
// a sibling path that merely starts with the same string ("/var/snap" grants
|
|
293
|
+
// "/var/snapshots-elsewhere"), and oam splits the list on commas with no
|
|
294
|
+
// escape, so a directory whose path contains a comma cannot be granted here.
|
|
295
|
+
const snapshotDir = process.env.CADDY_MCP_SNAPSHOT_DIR?.trim();
|
|
296
|
+
if (snapshotDir) {
|
|
297
|
+
const forms = [...new Set([snapshotDir, join(snapshotDir, ".")])].join(",");
|
|
298
|
+
flags.push(`--allow-fs-read=${forms}`, `--allow-fs-write=${forms}`);
|
|
299
|
+
}
|
|
184
300
|
|
|
185
|
-
const flags = ["--permission", netFlag, `--allow-env=${env.join(",")}`];
|
|
186
301
|
return flags;
|
|
187
302
|
}
|
|
188
303
|
|
package/dist/api.d.ts
CHANGED
|
@@ -5,6 +5,25 @@ export interface ApiResponse<T = any> {
|
|
|
5
5
|
error?: string;
|
|
6
6
|
etag?: string;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Caddy's "a segment of this config path does not exist" failure.
|
|
10
|
+
*
|
|
11
|
+
* Caddy walks a config path one segment at a time and reports the first segment it
|
|
12
|
+
* cannot enter as `invalid traversal path at: <path>`. The STATUS varies with the
|
|
13
|
+
* verb -- 400 on a GET, 500 on a POST, both observed on 2.11.4 -- so the body is
|
|
14
|
+
* the only signal that holds across call sites.
|
|
15
|
+
*
|
|
16
|
+
* DISTINCT from `404 key does not exist`, which Caddy emits when the object exists
|
|
17
|
+
* but the named sub-key does not (a PATCH of an absent issuer field, say). Both
|
|
18
|
+
* mean "what you named is not there", so a caller translating a missing parent
|
|
19
|
+
* usually wants both markers; matching only the 404 form leaves the traversal case
|
|
20
|
+
* falling through as a raw Go error.
|
|
21
|
+
*
|
|
22
|
+
* Exported because two tools need it and they live in different modules: the route
|
|
23
|
+
* tools translate it into "that server does not exist", and caddy_list_servers
|
|
24
|
+
* reads it as "no HTTP servers are configured at all".
|
|
25
|
+
*/
|
|
26
|
+
export declare function isMissingConfigPath(res: Pick<ApiResponse, "ok" | "error">): boolean;
|
|
8
27
|
export declare function configGet<T = any>(path?: string): Promise<ApiResponse<T>>;
|
|
9
28
|
export declare function configPost<T = any>(path: string, value: unknown): Promise<ApiResponse<T>>;
|
|
10
29
|
export declare function configPut<T = any>(path: string, value: unknown): Promise<ApiResponse<T>>;
|
package/dist/index.js
CHANGED
|
@@ -88,6 +88,9 @@ function getHeaders(contentType, overUnixSocket = false) {
|
|
|
88
88
|
function normalizePath(path) {
|
|
89
89
|
return path.replace(/^\/?(config(\/|$))?/, "");
|
|
90
90
|
}
|
|
91
|
+
function encodePathSegments(path) {
|
|
92
|
+
return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
93
|
+
}
|
|
91
94
|
function rejectTraversal(path) {
|
|
92
95
|
if (/(^|\/)\.\.(\/|$)/.test(path)) {
|
|
93
96
|
return {
|
|
@@ -107,6 +110,10 @@ function isTransientFailure(res) {
|
|
|
107
110
|
if (res.status >= 500 && res.status <= 599) return true;
|
|
108
111
|
return false;
|
|
109
112
|
}
|
|
113
|
+
function isMissingConfigPath(res) {
|
|
114
|
+
if (res.ok) return false;
|
|
115
|
+
return (res.error ?? "").toLowerCase().includes("invalid traversal path");
|
|
116
|
+
}
|
|
110
117
|
var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
|
|
111
118
|
function isRetryableMethod(method, path) {
|
|
112
119
|
if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
|
|
@@ -263,31 +270,31 @@ function configGet(path = "") {
|
|
|
263
270
|
const normalized = normalizePath(path);
|
|
264
271
|
const bad = rejectTraversal(normalized);
|
|
265
272
|
if (bad) return Promise.resolve(bad);
|
|
266
|
-
return caddyRequest("GET", `/config/${normalized}`);
|
|
273
|
+
return caddyRequest("GET", `/config/${encodePathSegments(normalized)}`);
|
|
267
274
|
}
|
|
268
275
|
function configPost(path, value) {
|
|
269
276
|
const normalized = normalizePath(path);
|
|
270
277
|
const bad = rejectTraversal(normalized);
|
|
271
278
|
if (bad) return Promise.resolve(bad);
|
|
272
|
-
return caddyRequest("POST", `/config/${normalized}`, value);
|
|
279
|
+
return caddyRequest("POST", `/config/${encodePathSegments(normalized)}`, value);
|
|
273
280
|
}
|
|
274
281
|
function configPut(path, value) {
|
|
275
282
|
const normalized = normalizePath(path);
|
|
276
283
|
const bad = rejectTraversal(normalized);
|
|
277
284
|
if (bad) return Promise.resolve(bad);
|
|
278
|
-
return caddyRequest("PUT", `/config/${normalized}`, value);
|
|
285
|
+
return caddyRequest("PUT", `/config/${encodePathSegments(normalized)}`, value);
|
|
279
286
|
}
|
|
280
287
|
function configPatch(path, value) {
|
|
281
288
|
const normalized = normalizePath(path);
|
|
282
289
|
const bad = rejectTraversal(normalized);
|
|
283
290
|
if (bad) return Promise.resolve(bad);
|
|
284
|
-
return caddyRequest("PATCH", `/config/${normalized}`, value);
|
|
291
|
+
return caddyRequest("PATCH", `/config/${encodePathSegments(normalized)}`, value);
|
|
285
292
|
}
|
|
286
293
|
function configDelete(path) {
|
|
287
294
|
const normalized = normalizePath(path);
|
|
288
295
|
const bad = rejectTraversal(normalized);
|
|
289
296
|
if (bad) return Promise.resolve(bad);
|
|
290
|
-
return caddyRequest("DELETE", `/config/${normalized}`);
|
|
297
|
+
return caddyRequest("DELETE", `/config/${encodePathSegments(normalized)}`);
|
|
291
298
|
}
|
|
292
299
|
function getRequestTimeout() {
|
|
293
300
|
const raw = process.env.CADDY_TIMEOUT;
|
|
@@ -324,19 +331,23 @@ function getUpstreams() {
|
|
|
324
331
|
function getPki(ca = "local") {
|
|
325
332
|
const bad = rejectTraversal(ca);
|
|
326
333
|
if (bad) return Promise.resolve(bad);
|
|
327
|
-
return caddyRequest("GET", `/pki/ca/${ca}`);
|
|
334
|
+
return caddyRequest("GET", `/pki/ca/${encodePathSegments(ca)}`);
|
|
328
335
|
}
|
|
329
336
|
function getPkiCertificates(ca = "local") {
|
|
330
337
|
const bad = rejectTraversal(ca);
|
|
331
338
|
if (bad) return Promise.resolve(bad);
|
|
332
|
-
return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
|
|
339
|
+
return caddyRequest("GET", `/pki/ca/${encodePathSegments(ca)}/certificates`);
|
|
340
|
+
}
|
|
341
|
+
function idPath(id, subpath) {
|
|
342
|
+
const encodedId = encodePathSegments(id);
|
|
343
|
+
return subpath ? `/id/${encodedId}/${encodePathSegments(subpath)}` : `/id/${encodedId}`;
|
|
333
344
|
}
|
|
334
345
|
function configByIdGet(id, subpath = "") {
|
|
335
346
|
const badId = rejectTraversal(id);
|
|
336
347
|
if (badId) return Promise.resolve(badId);
|
|
337
348
|
const bad = rejectTraversal(subpath);
|
|
338
349
|
if (bad) return Promise.resolve(bad);
|
|
339
|
-
const path =
|
|
350
|
+
const path = idPath(id, subpath);
|
|
340
351
|
return caddyRequest("GET", path);
|
|
341
352
|
}
|
|
342
353
|
function configByIdSet(id, value, method = "PATCH", subpath = "") {
|
|
@@ -344,7 +355,7 @@ function configByIdSet(id, value, method = "PATCH", subpath = "") {
|
|
|
344
355
|
if (badId) return Promise.resolve(badId);
|
|
345
356
|
const bad = rejectTraversal(subpath);
|
|
346
357
|
if (bad) return Promise.resolve(bad);
|
|
347
|
-
const path =
|
|
358
|
+
const path = idPath(id, subpath);
|
|
348
359
|
return caddyRequest(method, path, value);
|
|
349
360
|
}
|
|
350
361
|
function configByIdDelete(id, subpath = "") {
|
|
@@ -352,7 +363,7 @@ function configByIdDelete(id, subpath = "") {
|
|
|
352
363
|
if (badId) return Promise.resolve(badId);
|
|
353
364
|
const bad = rejectTraversal(subpath);
|
|
354
365
|
if (bad) return Promise.resolve(bad);
|
|
355
|
-
const path =
|
|
366
|
+
const path = idPath(id, subpath);
|
|
356
367
|
return caddyRequest("DELETE", path);
|
|
357
368
|
}
|
|
358
369
|
function getMetrics() {
|
|
@@ -381,7 +392,8 @@ function describeServer(rawValue) {
|
|
|
381
392
|
const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
|
382
393
|
const listen = Array.isArray(raw.listen) ? raw.listen : [];
|
|
383
394
|
const routes = Array.isArray(raw.routes) ? raw.routes : [];
|
|
384
|
-
const
|
|
395
|
+
const tlsPolicies = raw.tls_connection_policies;
|
|
396
|
+
const hasExplicitTls = Array.isArray(tlsPolicies) ? tlsPolicies.length > 0 : !!tlsPolicies;
|
|
385
397
|
const listensHttps = listen.some((l) => typeof l === "string" && HTTPS_PORT_RE.test(l));
|
|
386
398
|
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
387
399
|
const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
|
|
@@ -465,6 +477,9 @@ ACME email: ${email}`);
|
|
|
465
477
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
466
478
|
async () => {
|
|
467
479
|
const res = await configGet("apps/http/servers");
|
|
480
|
+
if (isMissingConfigPath(res)) {
|
|
481
|
+
return { content: [{ type: "text", text: "No HTTP servers configured" }] };
|
|
482
|
+
}
|
|
468
483
|
if (!res.ok) return formatResult(res);
|
|
469
484
|
const servers = res.data ?? {};
|
|
470
485
|
const names = Object.keys(servers);
|
|
@@ -535,6 +550,9 @@ ${lines.join("\n")}` }]
|
|
|
535
550
|
}
|
|
536
551
|
|
|
537
552
|
// src/resources.ts
|
|
553
|
+
function errorText(res) {
|
|
554
|
+
return `Error: ${res.error || `HTTP ${res.status}`}`;
|
|
555
|
+
}
|
|
538
556
|
function registerResources(server) {
|
|
539
557
|
server.resource("caddy-config", "caddy://config", { description: "Current Caddy JSON configuration" }, async () => {
|
|
540
558
|
const res = await configGet();
|
|
@@ -543,7 +561,7 @@ function registerResources(server) {
|
|
|
543
561
|
{
|
|
544
562
|
uri: "caddy://config",
|
|
545
563
|
mimeType: res.ok ? "application/json" : "text/plain",
|
|
546
|
-
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) :
|
|
564
|
+
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
|
|
547
565
|
}
|
|
548
566
|
]
|
|
549
567
|
};
|
|
@@ -559,7 +577,7 @@ function registerResources(server) {
|
|
|
559
577
|
{
|
|
560
578
|
uri: "caddy://upstreams",
|
|
561
579
|
mimeType: res.ok ? "application/json" : "text/plain",
|
|
562
|
-
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) :
|
|
580
|
+
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
|
|
563
581
|
}
|
|
564
582
|
]
|
|
565
583
|
};
|
|
@@ -579,7 +597,7 @@ function registerResources(server) {
|
|
|
579
597
|
{
|
|
580
598
|
uri: "caddy://metrics",
|
|
581
599
|
mimeType: "text/plain",
|
|
582
|
-
text:
|
|
600
|
+
text: errorText(res)
|
|
583
601
|
}
|
|
584
602
|
]
|
|
585
603
|
};
|
|
@@ -607,7 +625,7 @@ function registerResources(server) {
|
|
|
607
625
|
{
|
|
608
626
|
uri: "caddy://servers",
|
|
609
627
|
mimeType: res.ok ? "application/json" : "text/plain",
|
|
610
|
-
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) :
|
|
628
|
+
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
|
|
611
629
|
}
|
|
612
630
|
]
|
|
613
631
|
};
|
|
@@ -768,7 +786,15 @@ function registerConfigTools(server) {
|
|
|
768
786
|
path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
|
|
769
787
|
confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
|
|
770
788
|
},
|
|
771
|
-
|
|
789
|
+
// idempotentHint is FALSE because the hint covers the whole tool and cannot see
|
|
790
|
+
// the path it is called with. This tool's own documented example ends in an
|
|
791
|
+
// array index -- 'apps/http/servers/srv0/routes/0' -- and Caddy re-packs an
|
|
792
|
+
// array after a delete, so repeating that call removes a DIFFERENT route each
|
|
793
|
+
// time. caddy_remove_route carries the same correction for the byte-identical
|
|
794
|
+
// underlying request; the two must agree. Nothing here is auto-recoverable
|
|
795
|
+
// either: only caddy_load captures a snapshot, so a spurious repeat cannot be
|
|
796
|
+
// undone with caddy_revert.
|
|
797
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
772
798
|
async ({ path, confirm }) => {
|
|
773
799
|
if (!confirm) {
|
|
774
800
|
return {
|
|
@@ -880,13 +906,16 @@ ${lines.join("\n")}` }] };
|
|
|
880
906
|
const current = await configGet();
|
|
881
907
|
const res = await loadConfig(snap.config, "application/json");
|
|
882
908
|
if (!res.ok) return formatResult(res);
|
|
883
|
-
|
|
909
|
+
const capturedRollforward = current.ok && isSnapshotableConfig(current.data);
|
|
910
|
+
if (capturedRollforward) {
|
|
884
911
|
saveSnapshot(current.data, "caddy_revert");
|
|
885
912
|
}
|
|
886
913
|
const when = new Date(snap.timestamp).toISOString();
|
|
914
|
+
const skipped = current.ok ? "the pre-revert config was empty or not a JSON object" : "the pre-revert config could not be read";
|
|
915
|
+
const note = capturedRollforward ? "" : ` Warning: ${skipped}, so no roll-forward snapshot was captured -- this revert cannot be rolled back to the config it replaced.`;
|
|
887
916
|
return {
|
|
888
917
|
content: [
|
|
889
|
-
{ type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger})
|
|
918
|
+
{ type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger}).${note}` }
|
|
890
919
|
]
|
|
891
920
|
};
|
|
892
921
|
}
|
|
@@ -904,7 +933,13 @@ ${lines.join("\n")}` }] };
|
|
|
904
933
|
),
|
|
905
934
|
confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
|
|
906
935
|
},
|
|
907
|
-
|
|
936
|
+
// destructiveHint is keyed to the worst thing this tool can do, not the
|
|
937
|
+
// default action: action='delete' removes the identified object and every
|
|
938
|
+
// descendant, exactly like caddy_config_delete. Hosts gate on the hint
|
|
939
|
+
// before they can see which action the call carries.
|
|
940
|
+
// idempotentHint stays false for the same reason -- action='set' with
|
|
941
|
+
// mode='append' is a POST, so a repeated call appends a second copy.
|
|
942
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
908
943
|
async ({ id, action, value, subpath, mode, confirm }) => {
|
|
909
944
|
if (action === "get") {
|
|
910
945
|
return formatResult(await configByIdGet(id, subpath));
|
|
@@ -934,7 +969,8 @@ ${lines.join("\n")}` }] };
|
|
|
934
969
|
}
|
|
935
970
|
return formatResult(await configByIdDelete(id, subpath));
|
|
936
971
|
}
|
|
937
|
-
|
|
972
|
+
const unhandled = action;
|
|
973
|
+
return { isError: true, content: [{ type: "text", text: `Unknown action: ${String(unhandled)}` }] };
|
|
938
974
|
}
|
|
939
975
|
);
|
|
940
976
|
}
|
|
@@ -945,7 +981,7 @@ var ROUTES_JSON_MAX_CHARS = 2e4;
|
|
|
945
981
|
var ROUTES_SUMMARY_MAX = 500;
|
|
946
982
|
function serializeRoutesCapped(routes) {
|
|
947
983
|
const parts = [];
|
|
948
|
-
let used =
|
|
984
|
+
let used = 3;
|
|
949
985
|
for (const route of routes) {
|
|
950
986
|
const entry = JSON.stringify(route, null, 2).split("\n").map((line) => ` ${line}`).join("\n");
|
|
951
987
|
const cost = entry.length + (parts.length > 0 ? 2 : 1);
|
|
@@ -995,9 +1031,42 @@ function parseFrom(from) {
|
|
|
995
1031
|
function cleanUpstreamAddr(addr) {
|
|
996
1032
|
return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
997
1033
|
}
|
|
1034
|
+
function withDefaultPort(dial, port) {
|
|
1035
|
+
if (stripPort(dial) !== dial) return dial;
|
|
1036
|
+
const bareIpv6 = !dial.startsWith("[") && dial.indexOf(":") !== dial.lastIndexOf(":");
|
|
1037
|
+
return bareIpv6 ? `[${dial}]:${port}` : `${dial}:${port}`;
|
|
1038
|
+
}
|
|
1039
|
+
function planUpstreams(to) {
|
|
1040
|
+
if (to.length === 0) {
|
|
1041
|
+
return { error: `"to" must list at least one upstream address (e.g. ["localhost:3000"]).` };
|
|
1042
|
+
}
|
|
1043
|
+
const dials = [];
|
|
1044
|
+
let secure = 0;
|
|
1045
|
+
let plain = 0;
|
|
1046
|
+
for (const raw of to) {
|
|
1047
|
+
const trimmed = raw.trim();
|
|
1048
|
+
const isTls = trimmed.startsWith("https://");
|
|
1049
|
+
const dial = cleanUpstreamAddr(trimmed);
|
|
1050
|
+
if (dial.length === 0) {
|
|
1051
|
+
return {
|
|
1052
|
+
error: `upstream ${JSON.stringify(raw)} has no address to dial. Each "to" entry needs a host and port (e.g. "localhost:3000", "https://backend.example.com:8443").`
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
if (isTls) secure++;
|
|
1056
|
+
else plain++;
|
|
1057
|
+
dials.push(isTls ? withDefaultPort(dial, 443) : dial);
|
|
1058
|
+
}
|
|
1059
|
+
if (secure > 0 && plain > 0) {
|
|
1060
|
+
return {
|
|
1061
|
+
error: `"to" mixes https:// and non-https upstreams. Caddy's TLS transport applies to the whole reverse_proxy handler, not per-upstream, so one scheme would be silently forced on the other's connection. Split them into two routes, or build the handler explicitly with caddy_add_route.`
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
return { dials, tls: secure > 0 };
|
|
1065
|
+
}
|
|
998
1066
|
function isParentMissing(res) {
|
|
999
1067
|
if (res.ok) return false;
|
|
1000
1068
|
if (res.status === 404) return true;
|
|
1069
|
+
if (isMissingConfigPath(res)) return true;
|
|
1001
1070
|
return res.error?.includes("key does not exist") ?? false;
|
|
1002
1071
|
}
|
|
1003
1072
|
function isUnknownId(res) {
|
|
@@ -1016,7 +1085,18 @@ function serverNotFoundError(srv, op = "operation") {
|
|
|
1016
1085
|
content: [
|
|
1017
1086
|
{
|
|
1018
1087
|
type: "text",
|
|
1019
|
-
text: `Error: Server "${srv}" does not exist (${op}). Use caddy_list_servers to see
|
|
1088
|
+
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: "append", value: { "listen": [":443"], "routes": [] } }. Both arguments are load-bearing: mode "append" creates the key, while 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. On an instance with no config at all, use caddy_load instead -- caddy_config_set cannot create the apps/http tree it would write into.`
|
|
1089
|
+
}
|
|
1090
|
+
]
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
function serverNullError(srv) {
|
|
1094
|
+
return {
|
|
1095
|
+
isError: true,
|
|
1096
|
+
content: [
|
|
1097
|
+
{
|
|
1098
|
+
type: "text",
|
|
1099
|
+
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, or create this one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
|
|
1020
1100
|
}
|
|
1021
1101
|
]
|
|
1022
1102
|
};
|
|
@@ -1024,10 +1104,12 @@ function serverNotFoundError(srv, op = "operation") {
|
|
|
1024
1104
|
function registerRouteTools(server) {
|
|
1025
1105
|
server.tool(
|
|
1026
1106
|
"caddy_reverse_proxy",
|
|
1027
|
-
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PATCH under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
|
|
1107
|
+
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PATCH under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument. Upstream scheme is honored: an `https://` upstream gets a TLS transport and defaults to port 443, anything else is dialed in the clear. A `to` list that MIXES https:// and non-https entries is refused \u2014 the TLS transport applies to the whole handler, not per-upstream \u2014 so split those into two routes or use caddy_add_route.",
|
|
1028
1108
|
{
|
|
1029
1109
|
from: z4.string().min(1).describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
1030
|
-
to: z4.array(z4.string()).describe(
|
|
1110
|
+
to: z4.array(z4.string().min(1)).min(1).describe(
|
|
1111
|
+
"Upstream addresses, at least one (e.g., ['localhost:3000', 'localhost:3001']). An 'https://' prefix dials the upstream over TLS (port 443 unless one is given); http:// and bare addresses are dialed in the clear. Do not mix https:// and non-https entries in one call."
|
|
1112
|
+
),
|
|
1031
1113
|
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
1032
1114
|
id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
|
|
1033
1115
|
"Optional stable @id for the route. When set, repeat calls REPLACE the route in place (idempotent). When omitted, the route is APPENDED \u2014 calling twice with identical args creates a duplicate route. @ids are config-global in Caddy: if this id is already used by a non-route object the call refuses rather than clobbering it."
|
|
@@ -1047,15 +1129,24 @@ function registerRouteTools(server) {
|
|
|
1047
1129
|
]
|
|
1048
1130
|
};
|
|
1049
1131
|
}
|
|
1050
|
-
const
|
|
1132
|
+
const plan = planUpstreams(to);
|
|
1133
|
+
if ("error" in plan) {
|
|
1134
|
+
return {
|
|
1135
|
+
isError: true,
|
|
1136
|
+
content: [{ type: "text", text: `Error: ${plan.error}` }]
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
const cleanedTo = plan.dials;
|
|
1140
|
+
const proxyHandler = {
|
|
1141
|
+
handler: "reverse_proxy",
|
|
1142
|
+
upstreams: cleanedTo.map((addr) => ({ dial: addr }))
|
|
1143
|
+
};
|
|
1144
|
+
if (plan.tls) {
|
|
1145
|
+
proxyHandler.transport = { protocol: "http", tls: {} };
|
|
1146
|
+
}
|
|
1051
1147
|
const route = {
|
|
1052
1148
|
match: [match],
|
|
1053
|
-
handle: [
|
|
1054
|
-
{
|
|
1055
|
-
handler: "reverse_proxy",
|
|
1056
|
-
upstreams: cleanedTo.map((addr) => ({ dial: addr }))
|
|
1057
|
-
}
|
|
1058
|
-
],
|
|
1149
|
+
handle: [proxyHandler],
|
|
1059
1150
|
terminal: true
|
|
1060
1151
|
};
|
|
1061
1152
|
if (id) {
|
|
@@ -1143,7 +1234,11 @@ function registerRouteTools(server) {
|
|
|
1143
1234
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1144
1235
|
async ({ server: srv }) => {
|
|
1145
1236
|
const serverRes = await configGet(`apps/http/servers/${srv}`);
|
|
1237
|
+
if (isMissingConfigPath(serverRes)) return serverNotFoundError(srv, "caddy_list_routes");
|
|
1146
1238
|
if (!serverRes.ok) return formatResult(serverRes);
|
|
1239
|
+
if (serverRes.data === null || serverRes.data === void 0) {
|
|
1240
|
+
return serverNullError(srv);
|
|
1241
|
+
}
|
|
1147
1242
|
const serverConfig = serverRes.data || {};
|
|
1148
1243
|
const routes = Array.isArray(serverConfig.routes) ? serverConfig.routes : [];
|
|
1149
1244
|
const listen = Array.isArray(serverConfig.listen) ? serverConfig.listen : [];
|
|
@@ -1280,14 +1375,19 @@ function registerRouteTools(server) {
|
|
|
1280
1375
|
);
|
|
1281
1376
|
server.tool(
|
|
1282
1377
|
"caddy_remove_route",
|
|
1283
|
-
"Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible.",
|
|
1378
|
+
"Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible. Only the @id mode is idempotent: a repeat call cannot remove a different route, it just reports the id as gone. The index mode is NOT \u2014 Caddy re-packs the routes array after a removal, so calling with index 2 twice removes TWO DIFFERENT routes. @ids are config-global in Caddy (NOT route-scoped): if `id` resolves to a non-route object (TLS issuer, server, etc.) the call refuses rather than deleting it.",
|
|
1284
1379
|
{
|
|
1285
1380
|
id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
|
|
1286
1381
|
index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
|
|
1287
1382
|
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name when using index (default: srv0). Ignored when id is provided."),
|
|
1288
1383
|
confirm: z4.boolean().optional().default(false).describe("Must be true to actually remove the route (safety)")
|
|
1289
1384
|
},
|
|
1290
|
-
|
|
1385
|
+
// idempotentHint is false because the hint covers the TOOL, and hosts gate
|
|
1386
|
+
// on it before they can see which targeting mode a given call carries. The
|
|
1387
|
+
// @id path is idempotent; the index path is not -- Caddy re-packs the
|
|
1388
|
+
// routes array on removal, so a repeated index deletes a different route
|
|
1389
|
+
// each time. The weaker of the two has to win.
|
|
1390
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
1291
1391
|
async ({ id, index, server: srv, confirm }) => {
|
|
1292
1392
|
if (!id && index === void 0) {
|
|
1293
1393
|
return {
|
|
@@ -1308,6 +1408,19 @@ function registerRouteTools(server) {
|
|
|
1308
1408
|
};
|
|
1309
1409
|
}
|
|
1310
1410
|
if (id) {
|
|
1411
|
+
const existing = await configByIdGet(id);
|
|
1412
|
+
if (!existing.ok) return formatResult(existing);
|
|
1413
|
+
if (!isRouteShape(existing.data)) {
|
|
1414
|
+
return {
|
|
1415
|
+
isError: true,
|
|
1416
|
+
content: [
|
|
1417
|
+
{
|
|
1418
|
+
type: "text",
|
|
1419
|
+
text: `Error: @id "${id}" resolves to a non-route config object (no top-level "handle" array). @ids are config-global in Caddy, not route-scoped -- refusing to delete it as a route. Inspect it with caddy_config_by_id { id: "${id}", action: "get" }, and if you did mean to remove that object, delete it deliberately with caddy_config_by_id { id: "${id}", action: "delete", confirm: true }.`
|
|
1420
|
+
}
|
|
1421
|
+
]
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1311
1424
|
const res2 = await configByIdDelete(id);
|
|
1312
1425
|
if (res2.ok) return { content: [{ type: "text", text: `Route @id="${id}" removed.` }] };
|
|
1313
1426
|
return formatResult(res2);
|
|
@@ -1419,6 +1532,30 @@ function validateIssuerShape(tls) {
|
|
|
1419
1532
|
}
|
|
1420
1533
|
return null;
|
|
1421
1534
|
}
|
|
1535
|
+
var ACME_ISSUER_MODULE = "acme";
|
|
1536
|
+
function validateIssuerModule(tls) {
|
|
1537
|
+
const automation = tls.automation;
|
|
1538
|
+
const found = automation.policies[0].issuers[0].module;
|
|
1539
|
+
if (found === ACME_ISSUER_MODULE) return null;
|
|
1540
|
+
const describe = found === void 0 ? "absent" : JSON.stringify(found);
|
|
1541
|
+
return `Refusing to write an ACME field onto a non-ACME issuer: apps/tls.automation.policies[0].issuers[0].module is ${describe}, not "${ACME_ISSUER_MODULE}". email/ca/profile are fields of the acme issuer module only. Use caddy_config_set with an explicit path to edit this issuer, or point this tool at a config whose first policy uses an acme issuer.`;
|
|
1542
|
+
}
|
|
1543
|
+
function refuseFallback(label, patchRes, detail) {
|
|
1544
|
+
return {
|
|
1545
|
+
kind: "tool-error",
|
|
1546
|
+
result: {
|
|
1547
|
+
isError: true,
|
|
1548
|
+
content: [
|
|
1549
|
+
{
|
|
1550
|
+
type: "text",
|
|
1551
|
+
text: `Error: Failed to set ${label}.
|
|
1552
|
+
PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
|
|
1553
|
+
${detail}`
|
|
1554
|
+
}
|
|
1555
|
+
]
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1422
1559
|
async function safeFallback(label, patchRes, fields) {
|
|
1423
1560
|
const getRes = await configGet("apps/tls");
|
|
1424
1561
|
const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
|
|
@@ -1431,37 +1568,23 @@ async function safeFallback(label, patchRes, fields) {
|
|
|
1431
1568
|
return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
|
|
1432
1569
|
}
|
|
1433
1570
|
if (!isPlainObject(getRes.data)) {
|
|
1434
|
-
return
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
{
|
|
1440
|
-
type: "text",
|
|
1441
|
-
text: `Error: Failed to set ${label}.
|
|
1442
|
-
PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
|
|
1443
|
-
Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
|
|
1444
|
-
}
|
|
1445
|
-
]
|
|
1446
|
-
}
|
|
1447
|
-
};
|
|
1571
|
+
return refuseFallback(
|
|
1572
|
+
label,
|
|
1573
|
+
patchRes,
|
|
1574
|
+
`Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
|
|
1575
|
+
);
|
|
1448
1576
|
}
|
|
1449
1577
|
const shapeError = validateIssuerShape(getRes.data);
|
|
1450
1578
|
if (shapeError) {
|
|
1451
|
-
return
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
Refusing to clobber existing apps/tls: ${shapeError}. Use caddy_config_set with an explicit path (e.g. apps/tls/automation/policies) to update it safely.`
|
|
1461
|
-
}
|
|
1462
|
-
]
|
|
1463
|
-
}
|
|
1464
|
-
};
|
|
1579
|
+
return refuseFallback(
|
|
1580
|
+
label,
|
|
1581
|
+
patchRes,
|
|
1582
|
+
`Refusing to clobber existing apps/tls: ${shapeError}. Use caddy_config_set with an explicit path (e.g. apps/tls/automation/policies) to update it safely.`
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
const moduleError = validateIssuerModule(getRes.data);
|
|
1586
|
+
if (moduleError) {
|
|
1587
|
+
return refuseFallback(label, patchRes, moduleError);
|
|
1465
1588
|
}
|
|
1466
1589
|
const merged = mergeIssuerFields(getRes.data, fields);
|
|
1467
1590
|
const mergeRes = await configPatch("apps/tls", merged);
|
|
@@ -1481,7 +1604,7 @@ function missingArgError(text) {
|
|
|
1481
1604
|
function registerTlsTools(server) {
|
|
1482
1605
|
server.tool(
|
|
1483
1606
|
"caddy_tls",
|
|
1484
|
-
"Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL, 'set_acme_profile' sets the ACME profile (Caddy 2.10+), 'ech_status' reads the Encrypted ClientHello config (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances. Writes target policies[0].issuers[0] only -- on a multi-policy TLS config, edit the intended
|
|
1607
|
+
"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.",
|
|
1485
1608
|
{
|
|
1486
1609
|
action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
|
|
1487
1610
|
email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
|
|
@@ -1522,7 +1645,8 @@ function registerTlsTools(server) {
|
|
|
1522
1645
|
if (!profile) return missingArgError("profile is required for set_acme_profile action");
|
|
1523
1646
|
return setIssuerField("profile", profile, "ACME profile");
|
|
1524
1647
|
}
|
|
1525
|
-
|
|
1648
|
+
const unhandled = action;
|
|
1649
|
+
return { isError: true, content: [{ type: "text", text: `Unknown action: ${String(unhandled)}` }] };
|
|
1526
1650
|
}
|
|
1527
1651
|
);
|
|
1528
1652
|
}
|
package/dist/server.js
CHANGED
|
@@ -86,6 +86,9 @@ function getHeaders(contentType, overUnixSocket = false) {
|
|
|
86
86
|
function normalizePath(path) {
|
|
87
87
|
return path.replace(/^\/?(config(\/|$))?/, "");
|
|
88
88
|
}
|
|
89
|
+
function encodePathSegments(path) {
|
|
90
|
+
return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
91
|
+
}
|
|
89
92
|
function rejectTraversal(path) {
|
|
90
93
|
if (/(^|\/)\.\.(\/|$)/.test(path)) {
|
|
91
94
|
return {
|
|
@@ -105,6 +108,10 @@ function isTransientFailure(res) {
|
|
|
105
108
|
if (res.status >= 500 && res.status <= 599) return true;
|
|
106
109
|
return false;
|
|
107
110
|
}
|
|
111
|
+
function isMissingConfigPath(res) {
|
|
112
|
+
if (res.ok) return false;
|
|
113
|
+
return (res.error ?? "").toLowerCase().includes("invalid traversal path");
|
|
114
|
+
}
|
|
108
115
|
var ARRAY_INDEX_TAIL_RE = /\/\d+$/;
|
|
109
116
|
function isRetryableMethod(method, path) {
|
|
110
117
|
if (method === "PUT") return !ARRAY_INDEX_TAIL_RE.test(path);
|
|
@@ -261,31 +268,31 @@ function configGet(path = "") {
|
|
|
261
268
|
const normalized = normalizePath(path);
|
|
262
269
|
const bad = rejectTraversal(normalized);
|
|
263
270
|
if (bad) return Promise.resolve(bad);
|
|
264
|
-
return caddyRequest("GET", `/config/${normalized}`);
|
|
271
|
+
return caddyRequest("GET", `/config/${encodePathSegments(normalized)}`);
|
|
265
272
|
}
|
|
266
273
|
function configPost(path, value) {
|
|
267
274
|
const normalized = normalizePath(path);
|
|
268
275
|
const bad = rejectTraversal(normalized);
|
|
269
276
|
if (bad) return Promise.resolve(bad);
|
|
270
|
-
return caddyRequest("POST", `/config/${normalized}`, value);
|
|
277
|
+
return caddyRequest("POST", `/config/${encodePathSegments(normalized)}`, value);
|
|
271
278
|
}
|
|
272
279
|
function configPut(path, value) {
|
|
273
280
|
const normalized = normalizePath(path);
|
|
274
281
|
const bad = rejectTraversal(normalized);
|
|
275
282
|
if (bad) return Promise.resolve(bad);
|
|
276
|
-
return caddyRequest("PUT", `/config/${normalized}`, value);
|
|
283
|
+
return caddyRequest("PUT", `/config/${encodePathSegments(normalized)}`, value);
|
|
277
284
|
}
|
|
278
285
|
function configPatch(path, value) {
|
|
279
286
|
const normalized = normalizePath(path);
|
|
280
287
|
const bad = rejectTraversal(normalized);
|
|
281
288
|
if (bad) return Promise.resolve(bad);
|
|
282
|
-
return caddyRequest("PATCH", `/config/${normalized}`, value);
|
|
289
|
+
return caddyRequest("PATCH", `/config/${encodePathSegments(normalized)}`, value);
|
|
283
290
|
}
|
|
284
291
|
function configDelete(path) {
|
|
285
292
|
const normalized = normalizePath(path);
|
|
286
293
|
const bad = rejectTraversal(normalized);
|
|
287
294
|
if (bad) return Promise.resolve(bad);
|
|
288
|
-
return caddyRequest("DELETE", `/config/${normalized}`);
|
|
295
|
+
return caddyRequest("DELETE", `/config/${encodePathSegments(normalized)}`);
|
|
289
296
|
}
|
|
290
297
|
function getRequestTimeout() {
|
|
291
298
|
const raw = process.env.CADDY_TIMEOUT;
|
|
@@ -322,19 +329,23 @@ function getUpstreams() {
|
|
|
322
329
|
function getPki(ca = "local") {
|
|
323
330
|
const bad = rejectTraversal(ca);
|
|
324
331
|
if (bad) return Promise.resolve(bad);
|
|
325
|
-
return caddyRequest("GET", `/pki/ca/${ca}`);
|
|
332
|
+
return caddyRequest("GET", `/pki/ca/${encodePathSegments(ca)}`);
|
|
326
333
|
}
|
|
327
334
|
function getPkiCertificates(ca = "local") {
|
|
328
335
|
const bad = rejectTraversal(ca);
|
|
329
336
|
if (bad) return Promise.resolve(bad);
|
|
330
|
-
return caddyRequest("GET", `/pki/ca/${ca}/certificates`);
|
|
337
|
+
return caddyRequest("GET", `/pki/ca/${encodePathSegments(ca)}/certificates`);
|
|
338
|
+
}
|
|
339
|
+
function idPath(id, subpath) {
|
|
340
|
+
const encodedId = encodePathSegments(id);
|
|
341
|
+
return subpath ? `/id/${encodedId}/${encodePathSegments(subpath)}` : `/id/${encodedId}`;
|
|
331
342
|
}
|
|
332
343
|
function configByIdGet(id, subpath = "") {
|
|
333
344
|
const badId = rejectTraversal(id);
|
|
334
345
|
if (badId) return Promise.resolve(badId);
|
|
335
346
|
const bad = rejectTraversal(subpath);
|
|
336
347
|
if (bad) return Promise.resolve(bad);
|
|
337
|
-
const path =
|
|
348
|
+
const path = idPath(id, subpath);
|
|
338
349
|
return caddyRequest("GET", path);
|
|
339
350
|
}
|
|
340
351
|
function configByIdSet(id, value, method = "PATCH", subpath = "") {
|
|
@@ -342,7 +353,7 @@ function configByIdSet(id, value, method = "PATCH", subpath = "") {
|
|
|
342
353
|
if (badId) return Promise.resolve(badId);
|
|
343
354
|
const bad = rejectTraversal(subpath);
|
|
344
355
|
if (bad) return Promise.resolve(bad);
|
|
345
|
-
const path =
|
|
356
|
+
const path = idPath(id, subpath);
|
|
346
357
|
return caddyRequest(method, path, value);
|
|
347
358
|
}
|
|
348
359
|
function configByIdDelete(id, subpath = "") {
|
|
@@ -350,7 +361,7 @@ function configByIdDelete(id, subpath = "") {
|
|
|
350
361
|
if (badId) return Promise.resolve(badId);
|
|
351
362
|
const bad = rejectTraversal(subpath);
|
|
352
363
|
if (bad) return Promise.resolve(bad);
|
|
353
|
-
const path =
|
|
364
|
+
const path = idPath(id, subpath);
|
|
354
365
|
return caddyRequest("DELETE", path);
|
|
355
366
|
}
|
|
356
367
|
function getMetrics() {
|
|
@@ -379,7 +390,8 @@ function describeServer(rawValue) {
|
|
|
379
390
|
const raw = rawValue !== null && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
|
380
391
|
const listen = Array.isArray(raw.listen) ? raw.listen : [];
|
|
381
392
|
const routes = Array.isArray(raw.routes) ? raw.routes : [];
|
|
382
|
-
const
|
|
393
|
+
const tlsPolicies = raw.tls_connection_policies;
|
|
394
|
+
const hasExplicitTls = Array.isArray(tlsPolicies) ? tlsPolicies.length > 0 : !!tlsPolicies;
|
|
383
395
|
const listensHttps = listen.some((l) => typeof l === "string" && HTTPS_PORT_RE.test(l));
|
|
384
396
|
const tls = hasExplicitTls ? "enabled" : listensHttps ? "auto (HTTPS)" : "off (HTTP only)";
|
|
385
397
|
const listenStr = listen.length > 0 ? listen.map(String).join(", ") : "default";
|
|
@@ -463,6 +475,9 @@ ACME email: ${email}`);
|
|
|
463
475
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
464
476
|
async () => {
|
|
465
477
|
const res = await configGet("apps/http/servers");
|
|
478
|
+
if (isMissingConfigPath(res)) {
|
|
479
|
+
return { content: [{ type: "text", text: "No HTTP servers configured" }] };
|
|
480
|
+
}
|
|
466
481
|
if (!res.ok) return formatResult(res);
|
|
467
482
|
const servers = res.data ?? {};
|
|
468
483
|
const names = Object.keys(servers);
|
|
@@ -533,6 +548,9 @@ ${lines.join("\n")}` }]
|
|
|
533
548
|
}
|
|
534
549
|
|
|
535
550
|
// src/resources.ts
|
|
551
|
+
function errorText(res) {
|
|
552
|
+
return `Error: ${res.error || `HTTP ${res.status}`}`;
|
|
553
|
+
}
|
|
536
554
|
function registerResources(server) {
|
|
537
555
|
server.resource("caddy-config", "caddy://config", { description: "Current Caddy JSON configuration" }, async () => {
|
|
538
556
|
const res = await configGet();
|
|
@@ -541,7 +559,7 @@ function registerResources(server) {
|
|
|
541
559
|
{
|
|
542
560
|
uri: "caddy://config",
|
|
543
561
|
mimeType: res.ok ? "application/json" : "text/plain",
|
|
544
|
-
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) :
|
|
562
|
+
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
|
|
545
563
|
}
|
|
546
564
|
]
|
|
547
565
|
};
|
|
@@ -557,7 +575,7 @@ function registerResources(server) {
|
|
|
557
575
|
{
|
|
558
576
|
uri: "caddy://upstreams",
|
|
559
577
|
mimeType: res.ok ? "application/json" : "text/plain",
|
|
560
|
-
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) :
|
|
578
|
+
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
|
|
561
579
|
}
|
|
562
580
|
]
|
|
563
581
|
};
|
|
@@ -577,7 +595,7 @@ function registerResources(server) {
|
|
|
577
595
|
{
|
|
578
596
|
uri: "caddy://metrics",
|
|
579
597
|
mimeType: "text/plain",
|
|
580
|
-
text:
|
|
598
|
+
text: errorText(res)
|
|
581
599
|
}
|
|
582
600
|
]
|
|
583
601
|
};
|
|
@@ -605,7 +623,7 @@ function registerResources(server) {
|
|
|
605
623
|
{
|
|
606
624
|
uri: "caddy://servers",
|
|
607
625
|
mimeType: res.ok ? "application/json" : "text/plain",
|
|
608
|
-
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) :
|
|
626
|
+
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : errorText(res)
|
|
609
627
|
}
|
|
610
628
|
]
|
|
611
629
|
};
|
|
@@ -766,7 +784,15 @@ function registerConfigTools(server) {
|
|
|
766
784
|
path: z3.string().describe("Config path to delete (e.g., 'apps/http/servers/srv0/routes/0')"),
|
|
767
785
|
confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete the config node (safety)")
|
|
768
786
|
},
|
|
769
|
-
|
|
787
|
+
// idempotentHint is FALSE because the hint covers the whole tool and cannot see
|
|
788
|
+
// the path it is called with. This tool's own documented example ends in an
|
|
789
|
+
// array index -- 'apps/http/servers/srv0/routes/0' -- and Caddy re-packs an
|
|
790
|
+
// array after a delete, so repeating that call removes a DIFFERENT route each
|
|
791
|
+
// time. caddy_remove_route carries the same correction for the byte-identical
|
|
792
|
+
// underlying request; the two must agree. Nothing here is auto-recoverable
|
|
793
|
+
// either: only caddy_load captures a snapshot, so a spurious repeat cannot be
|
|
794
|
+
// undone with caddy_revert.
|
|
795
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
770
796
|
async ({ path, confirm }) => {
|
|
771
797
|
if (!confirm) {
|
|
772
798
|
return {
|
|
@@ -878,13 +904,16 @@ ${lines.join("\n")}` }] };
|
|
|
878
904
|
const current = await configGet();
|
|
879
905
|
const res = await loadConfig(snap.config, "application/json");
|
|
880
906
|
if (!res.ok) return formatResult(res);
|
|
881
|
-
|
|
907
|
+
const capturedRollforward = current.ok && isSnapshotableConfig(current.data);
|
|
908
|
+
if (capturedRollforward) {
|
|
882
909
|
saveSnapshot(current.data, "caddy_revert");
|
|
883
910
|
}
|
|
884
911
|
const when = new Date(snap.timestamp).toISOString();
|
|
912
|
+
const skipped = current.ok ? "the pre-revert config was empty or not a JSON object" : "the pre-revert config could not be read";
|
|
913
|
+
const note = capturedRollforward ? "" : ` Warning: ${skipped}, so no roll-forward snapshot was captured -- this revert cannot be rolled back to the config it replaced.`;
|
|
885
914
|
return {
|
|
886
915
|
content: [
|
|
887
|
-
{ type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger})
|
|
916
|
+
{ type: "text", text: `Reverted to snapshot [${index}] (${when}, trigger=${snap.trigger}).${note}` }
|
|
888
917
|
]
|
|
889
918
|
};
|
|
890
919
|
}
|
|
@@ -902,7 +931,13 @@ ${lines.join("\n")}` }] };
|
|
|
902
931
|
),
|
|
903
932
|
confirm: z3.boolean().optional().default(false).describe("Must be true to actually delete (only enforced for action='delete')")
|
|
904
933
|
},
|
|
905
|
-
|
|
934
|
+
// destructiveHint is keyed to the worst thing this tool can do, not the
|
|
935
|
+
// default action: action='delete' removes the identified object and every
|
|
936
|
+
// descendant, exactly like caddy_config_delete. Hosts gate on the hint
|
|
937
|
+
// before they can see which action the call carries.
|
|
938
|
+
// idempotentHint stays false for the same reason -- action='set' with
|
|
939
|
+
// mode='append' is a POST, so a repeated call appends a second copy.
|
|
940
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
906
941
|
async ({ id, action, value, subpath, mode, confirm }) => {
|
|
907
942
|
if (action === "get") {
|
|
908
943
|
return formatResult(await configByIdGet(id, subpath));
|
|
@@ -932,7 +967,8 @@ ${lines.join("\n")}` }] };
|
|
|
932
967
|
}
|
|
933
968
|
return formatResult(await configByIdDelete(id, subpath));
|
|
934
969
|
}
|
|
935
|
-
|
|
970
|
+
const unhandled = action;
|
|
971
|
+
return { isError: true, content: [{ type: "text", text: `Unknown action: ${String(unhandled)}` }] };
|
|
936
972
|
}
|
|
937
973
|
);
|
|
938
974
|
}
|
|
@@ -943,7 +979,7 @@ var ROUTES_JSON_MAX_CHARS = 2e4;
|
|
|
943
979
|
var ROUTES_SUMMARY_MAX = 500;
|
|
944
980
|
function serializeRoutesCapped(routes) {
|
|
945
981
|
const parts = [];
|
|
946
|
-
let used =
|
|
982
|
+
let used = 3;
|
|
947
983
|
for (const route of routes) {
|
|
948
984
|
const entry = JSON.stringify(route, null, 2).split("\n").map((line) => ` ${line}`).join("\n");
|
|
949
985
|
const cost = entry.length + (parts.length > 0 ? 2 : 1);
|
|
@@ -993,9 +1029,42 @@ function parseFrom(from) {
|
|
|
993
1029
|
function cleanUpstreamAddr(addr) {
|
|
994
1030
|
return addr.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
995
1031
|
}
|
|
1032
|
+
function withDefaultPort(dial, port) {
|
|
1033
|
+
if (stripPort(dial) !== dial) return dial;
|
|
1034
|
+
const bareIpv6 = !dial.startsWith("[") && dial.indexOf(":") !== dial.lastIndexOf(":");
|
|
1035
|
+
return bareIpv6 ? `[${dial}]:${port}` : `${dial}:${port}`;
|
|
1036
|
+
}
|
|
1037
|
+
function planUpstreams(to) {
|
|
1038
|
+
if (to.length === 0) {
|
|
1039
|
+
return { error: `"to" must list at least one upstream address (e.g. ["localhost:3000"]).` };
|
|
1040
|
+
}
|
|
1041
|
+
const dials = [];
|
|
1042
|
+
let secure = 0;
|
|
1043
|
+
let plain = 0;
|
|
1044
|
+
for (const raw of to) {
|
|
1045
|
+
const trimmed = raw.trim();
|
|
1046
|
+
const isTls = trimmed.startsWith("https://");
|
|
1047
|
+
const dial = cleanUpstreamAddr(trimmed);
|
|
1048
|
+
if (dial.length === 0) {
|
|
1049
|
+
return {
|
|
1050
|
+
error: `upstream ${JSON.stringify(raw)} has no address to dial. Each "to" entry needs a host and port (e.g. "localhost:3000", "https://backend.example.com:8443").`
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
if (isTls) secure++;
|
|
1054
|
+
else plain++;
|
|
1055
|
+
dials.push(isTls ? withDefaultPort(dial, 443) : dial);
|
|
1056
|
+
}
|
|
1057
|
+
if (secure > 0 && plain > 0) {
|
|
1058
|
+
return {
|
|
1059
|
+
error: `"to" mixes https:// and non-https upstreams. Caddy's TLS transport applies to the whole reverse_proxy handler, not per-upstream, so one scheme would be silently forced on the other's connection. Split them into two routes, or build the handler explicitly with caddy_add_route.`
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
return { dials, tls: secure > 0 };
|
|
1063
|
+
}
|
|
996
1064
|
function isParentMissing(res) {
|
|
997
1065
|
if (res.ok) return false;
|
|
998
1066
|
if (res.status === 404) return true;
|
|
1067
|
+
if (isMissingConfigPath(res)) return true;
|
|
999
1068
|
return res.error?.includes("key does not exist") ?? false;
|
|
1000
1069
|
}
|
|
1001
1070
|
function isUnknownId(res) {
|
|
@@ -1014,7 +1083,18 @@ function serverNotFoundError(srv, op = "operation") {
|
|
|
1014
1083
|
content: [
|
|
1015
1084
|
{
|
|
1016
1085
|
type: "text",
|
|
1017
|
-
text: `Error: Server "${srv}" does not exist (${op}). Use caddy_list_servers to see
|
|
1086
|
+
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: "append", value: { "listen": [":443"], "routes": [] } }. Both arguments are load-bearing: mode "append" creates the key, while 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. On an instance with no config at all, use caddy_load instead -- caddy_config_set cannot create the apps/http tree it would write into.`
|
|
1087
|
+
}
|
|
1088
|
+
]
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
function serverNullError(srv) {
|
|
1092
|
+
return {
|
|
1093
|
+
isError: true,
|
|
1094
|
+
content: [
|
|
1095
|
+
{
|
|
1096
|
+
type: "text",
|
|
1097
|
+
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, or create this one with caddy_load or caddy_config_set at path 'apps/http/servers/${srv}' with at minimum: { "listen": [":443"] }`
|
|
1018
1098
|
}
|
|
1019
1099
|
]
|
|
1020
1100
|
};
|
|
@@ -1022,10 +1102,12 @@ function serverNotFoundError(srv, op = "operation") {
|
|
|
1022
1102
|
function registerRouteTools(server) {
|
|
1023
1103
|
server.tool(
|
|
1024
1104
|
"caddy_reverse_proxy",
|
|
1025
|
-
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PATCH under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument.",
|
|
1105
|
+
"Add a reverse proxy route. The most common operation \u2014 just specify where traffic comes from and where it goes. Example: from='api.local' to=['localhost:3000']. When `id` is OMITTED the route is appended to the server's routes array \u2014 calling the tool twice with the same args produces TWO duplicate routes (non-idempotent). When `id` is SUPPLIED the route is written via PATCH under that @id, so repeat calls REPLACE in place (idempotent). Strongly recommended: supply a stable `id` for any route managed from automation or production tooling. Note: @ids are config-global in Caddy (NOT route-scoped). If `id` collides with an @id used by a non-route object (TLS issuer, server, etc.) the call refuses with an error rather than clobbering it. Once an @id is registered to a route under one server, subsequent calls update that route in place regardless of the `server` argument. Upstream scheme is honored: an `https://` upstream gets a TLS transport and defaults to port 443, anything else is dialed in the clear. A `to` list that MIXES https:// and non-https entries is refused \u2014 the TLS transport applies to the whole handler, not per-upstream \u2014 so split those into two routes or use caddy_add_route.",
|
|
1026
1106
|
{
|
|
1027
1107
|
from: z4.string().min(1).describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
1028
|
-
to: z4.array(z4.string()).describe(
|
|
1108
|
+
to: z4.array(z4.string().min(1)).min(1).describe(
|
|
1109
|
+
"Upstream addresses, at least one (e.g., ['localhost:3000', 'localhost:3001']). An 'https://' prefix dials the upstream over TLS (port 443 unless one is given); http:// and bare addresses are dialed in the clear. Do not mix https:// and non-https entries in one call."
|
|
1110
|
+
),
|
|
1029
1111
|
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
1030
1112
|
id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe(
|
|
1031
1113
|
"Optional stable @id for the route. When set, repeat calls REPLACE the route in place (idempotent). When omitted, the route is APPENDED \u2014 calling twice with identical args creates a duplicate route. @ids are config-global in Caddy: if this id is already used by a non-route object the call refuses rather than clobbering it."
|
|
@@ -1045,15 +1127,24 @@ function registerRouteTools(server) {
|
|
|
1045
1127
|
]
|
|
1046
1128
|
};
|
|
1047
1129
|
}
|
|
1048
|
-
const
|
|
1130
|
+
const plan = planUpstreams(to);
|
|
1131
|
+
if ("error" in plan) {
|
|
1132
|
+
return {
|
|
1133
|
+
isError: true,
|
|
1134
|
+
content: [{ type: "text", text: `Error: ${plan.error}` }]
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
const cleanedTo = plan.dials;
|
|
1138
|
+
const proxyHandler = {
|
|
1139
|
+
handler: "reverse_proxy",
|
|
1140
|
+
upstreams: cleanedTo.map((addr) => ({ dial: addr }))
|
|
1141
|
+
};
|
|
1142
|
+
if (plan.tls) {
|
|
1143
|
+
proxyHandler.transport = { protocol: "http", tls: {} };
|
|
1144
|
+
}
|
|
1049
1145
|
const route = {
|
|
1050
1146
|
match: [match],
|
|
1051
|
-
handle: [
|
|
1052
|
-
{
|
|
1053
|
-
handler: "reverse_proxy",
|
|
1054
|
-
upstreams: cleanedTo.map((addr) => ({ dial: addr }))
|
|
1055
|
-
}
|
|
1056
|
-
],
|
|
1147
|
+
handle: [proxyHandler],
|
|
1057
1148
|
terminal: true
|
|
1058
1149
|
};
|
|
1059
1150
|
if (id) {
|
|
@@ -1141,7 +1232,11 @@ function registerRouteTools(server) {
|
|
|
1141
1232
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1142
1233
|
async ({ server: srv }) => {
|
|
1143
1234
|
const serverRes = await configGet(`apps/http/servers/${srv}`);
|
|
1235
|
+
if (isMissingConfigPath(serverRes)) return serverNotFoundError(srv, "caddy_list_routes");
|
|
1144
1236
|
if (!serverRes.ok) return formatResult(serverRes);
|
|
1237
|
+
if (serverRes.data === null || serverRes.data === void 0) {
|
|
1238
|
+
return serverNullError(srv);
|
|
1239
|
+
}
|
|
1145
1240
|
const serverConfig = serverRes.data || {};
|
|
1146
1241
|
const routes = Array.isArray(serverConfig.routes) ? serverConfig.routes : [];
|
|
1147
1242
|
const listen = Array.isArray(serverConfig.listen) ? serverConfig.listen : [];
|
|
@@ -1278,14 +1373,19 @@ function registerRouteTools(server) {
|
|
|
1278
1373
|
);
|
|
1279
1374
|
server.tool(
|
|
1280
1375
|
"caddy_remove_route",
|
|
1281
|
-
"Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible.",
|
|
1376
|
+
"Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Index-based removal is a two-step read-then-delete and can race against concurrent edits; prefer @id when possible. Only the @id mode is idempotent: a repeat call cannot remove a different route, it just reports the id as gone. The index mode is NOT \u2014 Caddy re-packs the routes array after a removal, so calling with index 2 twice removes TWO DIFFERENT routes. @ids are config-global in Caddy (NOT route-scoped): if `id` resolves to a non-route object (TLS issuer, server, etc.) the call refuses rather than deleting it.",
|
|
1282
1377
|
{
|
|
1283
1378
|
id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
|
|
1284
1379
|
index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
|
|
1285
1380
|
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name when using index (default: srv0). Ignored when id is provided."),
|
|
1286
1381
|
confirm: z4.boolean().optional().default(false).describe("Must be true to actually remove the route (safety)")
|
|
1287
1382
|
},
|
|
1288
|
-
|
|
1383
|
+
// idempotentHint is false because the hint covers the TOOL, and hosts gate
|
|
1384
|
+
// on it before they can see which targeting mode a given call carries. The
|
|
1385
|
+
// @id path is idempotent; the index path is not -- Caddy re-packs the
|
|
1386
|
+
// routes array on removal, so a repeated index deletes a different route
|
|
1387
|
+
// each time. The weaker of the two has to win.
|
|
1388
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
1289
1389
|
async ({ id, index, server: srv, confirm }) => {
|
|
1290
1390
|
if (!id && index === void 0) {
|
|
1291
1391
|
return {
|
|
@@ -1306,6 +1406,19 @@ function registerRouteTools(server) {
|
|
|
1306
1406
|
};
|
|
1307
1407
|
}
|
|
1308
1408
|
if (id) {
|
|
1409
|
+
const existing = await configByIdGet(id);
|
|
1410
|
+
if (!existing.ok) return formatResult(existing);
|
|
1411
|
+
if (!isRouteShape(existing.data)) {
|
|
1412
|
+
return {
|
|
1413
|
+
isError: true,
|
|
1414
|
+
content: [
|
|
1415
|
+
{
|
|
1416
|
+
type: "text",
|
|
1417
|
+
text: `Error: @id "${id}" resolves to a non-route config object (no top-level "handle" array). @ids are config-global in Caddy, not route-scoped -- refusing to delete it as a route. Inspect it with caddy_config_by_id { id: "${id}", action: "get" }, and if you did mean to remove that object, delete it deliberately with caddy_config_by_id { id: "${id}", action: "delete", confirm: true }.`
|
|
1418
|
+
}
|
|
1419
|
+
]
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1309
1422
|
const res2 = await configByIdDelete(id);
|
|
1310
1423
|
if (res2.ok) return { content: [{ type: "text", text: `Route @id="${id}" removed.` }] };
|
|
1311
1424
|
return formatResult(res2);
|
|
@@ -1417,6 +1530,30 @@ function validateIssuerShape(tls) {
|
|
|
1417
1530
|
}
|
|
1418
1531
|
return null;
|
|
1419
1532
|
}
|
|
1533
|
+
var ACME_ISSUER_MODULE = "acme";
|
|
1534
|
+
function validateIssuerModule(tls) {
|
|
1535
|
+
const automation = tls.automation;
|
|
1536
|
+
const found = automation.policies[0].issuers[0].module;
|
|
1537
|
+
if (found === ACME_ISSUER_MODULE) return null;
|
|
1538
|
+
const describe = found === void 0 ? "absent" : JSON.stringify(found);
|
|
1539
|
+
return `Refusing to write an ACME field onto a non-ACME issuer: apps/tls.automation.policies[0].issuers[0].module is ${describe}, not "${ACME_ISSUER_MODULE}". email/ca/profile are fields of the acme issuer module only. Use caddy_config_set with an explicit path to edit this issuer, or point this tool at a config whose first policy uses an acme issuer.`;
|
|
1540
|
+
}
|
|
1541
|
+
function refuseFallback(label, patchRes, detail) {
|
|
1542
|
+
return {
|
|
1543
|
+
kind: "tool-error",
|
|
1544
|
+
result: {
|
|
1545
|
+
isError: true,
|
|
1546
|
+
content: [
|
|
1547
|
+
{
|
|
1548
|
+
type: "text",
|
|
1549
|
+
text: `Error: Failed to set ${label}.
|
|
1550
|
+
PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
|
|
1551
|
+
${detail}`
|
|
1552
|
+
}
|
|
1553
|
+
]
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1420
1557
|
async function safeFallback(label, patchRes, fields) {
|
|
1421
1558
|
const getRes = await configGet("apps/tls");
|
|
1422
1559
|
const absent = !getRes.ok && getRes.status === 404 ? true : getRes.ok && (getRes.data === void 0 || getRes.data === null);
|
|
@@ -1429,37 +1566,23 @@ async function safeFallback(label, patchRes, fields) {
|
|
|
1429
1566
|
return { kind: "tool-error", result: bothErrors(label, patchRes, getRes, "GET apps/tls") };
|
|
1430
1567
|
}
|
|
1431
1568
|
if (!isPlainObject(getRes.data)) {
|
|
1432
|
-
return
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
{
|
|
1438
|
-
type: "text",
|
|
1439
|
-
text: `Error: Failed to set ${label}.
|
|
1440
|
-
PATCH attempt: ${patchRes.error || `HTTP ${patchRes.status}`}
|
|
1441
|
-
Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
|
|
1442
|
-
}
|
|
1443
|
-
]
|
|
1444
|
-
}
|
|
1445
|
-
};
|
|
1569
|
+
return refuseFallback(
|
|
1570
|
+
label,
|
|
1571
|
+
patchRes,
|
|
1572
|
+
`Refusing to clobber existing apps/tls: GET returned a non-object value. Use caddy_config_set with an explicit path to update it safely.`
|
|
1573
|
+
);
|
|
1446
1574
|
}
|
|
1447
1575
|
const shapeError = validateIssuerShape(getRes.data);
|
|
1448
1576
|
if (shapeError) {
|
|
1449
|
-
return
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
Refusing to clobber existing apps/tls: ${shapeError}. Use caddy_config_set with an explicit path (e.g. apps/tls/automation/policies) to update it safely.`
|
|
1459
|
-
}
|
|
1460
|
-
]
|
|
1461
|
-
}
|
|
1462
|
-
};
|
|
1577
|
+
return refuseFallback(
|
|
1578
|
+
label,
|
|
1579
|
+
patchRes,
|
|
1580
|
+
`Refusing to clobber existing apps/tls: ${shapeError}. Use caddy_config_set with an explicit path (e.g. apps/tls/automation/policies) to update it safely.`
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
const moduleError = validateIssuerModule(getRes.data);
|
|
1584
|
+
if (moduleError) {
|
|
1585
|
+
return refuseFallback(label, patchRes, moduleError);
|
|
1463
1586
|
}
|
|
1464
1587
|
const merged = mergeIssuerFields(getRes.data, fields);
|
|
1465
1588
|
const mergeRes = await configPatch("apps/tls", merged);
|
|
@@ -1479,7 +1602,7 @@ function missingArgError(text) {
|
|
|
1479
1602
|
function registerTlsTools(server) {
|
|
1480
1603
|
server.tool(
|
|
1481
1604
|
"caddy_tls",
|
|
1482
|
-
"Get or configure TLS/HTTPS settings. Actions: 'status' shows current TLS config, 'set_email' sets the ACME email, 'set_acme_ca' sets the ACME CA URL, 'set_acme_profile' sets the ACME profile (Caddy 2.10+), 'ech_status' reads the Encrypted ClientHello config (Caddy 2.10+, read-only here). Works on both fresh and existing Caddy instances. Writes target policies[0].issuers[0] only -- on a multi-policy TLS config, edit the intended
|
|
1605
|
+
"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.",
|
|
1483
1606
|
{
|
|
1484
1607
|
action: z5.enum(["status", "set_email", "set_acme_ca", "set_acme_profile", "ech_status"]).describe("Action to perform"),
|
|
1485
1608
|
email: z5.string().optional().describe("ACME email address (for 'set_email' action)"),
|
|
@@ -1520,7 +1643,8 @@ function registerTlsTools(server) {
|
|
|
1520
1643
|
if (!profile) return missingArgError("profile is required for set_acme_profile action");
|
|
1521
1644
|
return setIssuerField("profile", profile, "ACME profile");
|
|
1522
1645
|
}
|
|
1523
|
-
|
|
1646
|
+
const unhandled = action;
|
|
1647
|
+
return { isError: true, content: [{ type: "text", text: `Unknown action: ${String(unhandled)}` }] };
|
|
1524
1648
|
}
|
|
1525
1649
|
);
|
|
1526
1650
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/caddy-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"mcpName": "io.github.YawLabs/caddy-mcp",
|
|
5
|
-
"description": "MCP server for
|
|
5
|
+
"description": "Caddy MCP server for Claude Code, Cursor, and any MCP client: admin API, config, routes, reverse proxy, TLS, PKI, metrics",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",
|
|
8
8
|
"type": "module",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"build": "tsup && tsc -p tsconfig.build.json",
|
|
29
29
|
"dev": "tsup --watch",
|
|
30
30
|
"test": "vitest run",
|
|
31
|
-
"lint": "
|
|
32
|
-
"lint:fix": "
|
|
31
|
+
"lint": "node scripts/lint.mjs check src/ bin/ scripts/",
|
|
32
|
+
"lint:fix": "node scripts/lint.mjs check --write src/ bin/ scripts/",
|
|
33
33
|
"typecheck": "node scripts/typecheck.mjs",
|
|
34
34
|
"typecheck:tsc": "tsc --noEmit",
|
|
35
35
|
"test:ci": "npm run build && npm test",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@biomejs/biome": "~2.4.11",
|
|
55
55
|
"@types/node": "^26.0.0",
|
|
56
|
+
"esbuild": "^0.28.1",
|
|
56
57
|
"postject": "^1.0.0-alpha.6",
|
|
57
58
|
"tsup": "^8.4.0",
|
|
58
59
|
"typescript": "^7.0.2",
|
|
@@ -62,12 +63,22 @@
|
|
|
62
63
|
"node": ">=20"
|
|
63
64
|
},
|
|
64
65
|
"keywords": [
|
|
66
|
+
"caddy",
|
|
67
|
+
"caddy-mcp",
|
|
68
|
+
"caddy-server",
|
|
69
|
+
"caddyfile",
|
|
65
70
|
"mcp",
|
|
71
|
+
"mcp-server",
|
|
66
72
|
"model-context-protocol",
|
|
67
|
-
"caddy",
|
|
68
73
|
"reverse-proxy",
|
|
69
74
|
"web-server",
|
|
70
|
-
"
|
|
75
|
+
"admin-api",
|
|
76
|
+
"tls",
|
|
77
|
+
"acme",
|
|
78
|
+
"pki",
|
|
79
|
+
"prometheus",
|
|
80
|
+
"claude-code",
|
|
81
|
+
"cursor",
|
|
71
82
|
"devtools",
|
|
72
83
|
"ai"
|
|
73
84
|
],
|
|
@@ -78,5 +89,5 @@
|
|
|
78
89
|
"bugs": {
|
|
79
90
|
"url": "https://github.com/YawLabs/caddy-mcp/issues"
|
|
80
91
|
},
|
|
81
|
-
"homepage": "https://
|
|
92
|
+
"homepage": "https://yaw.sh/mcp-servers/caddy-mcp/"
|
|
82
93
|
}
|