vite-plugin-caddy-multiple-tls 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +748 -136
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -112,6 +112,10 @@ export default config;
|
|
|
112
112
|
|
|
113
113
|
This derives a host like `<repo>.<branch>.web-1.localhost`.
|
|
114
114
|
|
|
115
|
+
The plugin now treats hostname ownership as explicit. If another live Vite server already owns the resolved domain, it will refuse takeover instead of deleting the other server's route. Use `instanceLabel`, `domain`, or stop the other server first.
|
|
116
|
+
|
|
117
|
+
If a previous Vite process crashed and left stale ownership behind, the plugin will reclaim it automatically and clean up the stale Caddy route before continuing.
|
|
118
|
+
|
|
115
119
|
For a zero-config experience, use `baseDomain: 'localhost'` (the default) so the derived domain works without editing `/etc/hosts`.
|
|
116
120
|
|
|
117
121
|
`internalTls` defaults to `true` when you pass `baseDomain` or `domain`. You can override it if needed.
|
|
@@ -136,6 +140,48 @@ const config = defineConfig({
|
|
|
136
140
|
export default config;
|
|
137
141
|
```
|
|
138
142
|
|
|
143
|
+
If your Caddy Admin API enforces a specific allowed origin that differs from `caddyApiUrl`, set `caddyAdminOrigin`.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
// vite.config.js
|
|
147
|
+
import { defineConfig } from 'vite';
|
|
148
|
+
import caddyTls from 'vite-plugin-caddy-multiple-tls';
|
|
149
|
+
|
|
150
|
+
const config = defineConfig({
|
|
151
|
+
plugins: [
|
|
152
|
+
caddyTls({
|
|
153
|
+
caddyApiUrl: 'http://127.0.0.1:2019',
|
|
154
|
+
caddyAdminOrigin: 'http://localhost:2019',
|
|
155
|
+
})
|
|
156
|
+
]
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
export default config;
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Troubleshooting
|
|
163
|
+
|
|
164
|
+
### `Cannot claim ... another Vite server already owns this domain`
|
|
165
|
+
|
|
166
|
+
This means another live dev server is already using the resolved hostname.
|
|
167
|
+
|
|
168
|
+
- Stop the other server if you want this one to use the same host.
|
|
169
|
+
- Add `instanceLabel` if both servers should run at the same time.
|
|
170
|
+
- Pass an explicit `domain` if you want total control over the hostname.
|
|
171
|
+
|
|
172
|
+
### `client is not allowed to access from origin ''`
|
|
173
|
+
|
|
174
|
+
This error comes from Caddy Admin API origin enforcement, not from Caddy being down.
|
|
175
|
+
|
|
176
|
+
- Check `caddyApiUrl` points to the correct Admin API endpoint.
|
|
177
|
+
- If Admin API expects a different origin than the API URL host, set `caddyAdminOrigin`.
|
|
178
|
+
- Verify behavior directly:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
curl -i http://127.0.0.1:2019/config/
|
|
182
|
+
curl -i -H 'Origin: http://127.0.0.1:2019' http://127.0.0.1:2019/config/
|
|
183
|
+
```
|
|
184
|
+
|
|
139
185
|
> [!IMPORTANT]
|
|
140
186
|
> **Hosts file limitation:** If you use a custom domain, you must **manually** add each generated subdomain to your `/etc/hosts` file (e.g., `127.0.0.1 repo.branch.local.example.test`). System hosts files **do not support wildcards** (e.g., `*.local.example.test`), so you lose the benefit of automatic domain resolution that `localhost` provides.
|
|
141
187
|
|
package/dist/index.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ interface ViteCaddyTlsPluginOptions {
|
|
|
18
18
|
serverName?: string;
|
|
19
19
|
/** Override the Caddy Admin API base URL (default: http://localhost:2019) */
|
|
20
20
|
caddyApiUrl?: string;
|
|
21
|
+
/** Override the Origin header used for Caddy Admin API requests (defaults to caddyApiUrl origin) */
|
|
22
|
+
caddyAdminOrigin?: string;
|
|
21
23
|
/** Use Caddy's internal CA for the provided domains (defaults to true when baseDomain or domain is set) */
|
|
22
24
|
internalTls?: boolean;
|
|
23
25
|
/**
|
|
@@ -39,6 +41,6 @@ type LoopbackDomain = 'localtest.me' | 'lvh.me' | 'nip.io';
|
|
|
39
41
|
* ```
|
|
40
42
|
* @returns {Plugin} - a Vite plugin
|
|
41
43
|
*/
|
|
42
|
-
declare function viteCaddyTlsPlugin({ domain, baseDomain, loopbackDomain, repo, branch, instanceLabel, cors, serverName, caddyApiUrl, internalTls, upstreamHostHeader, }?: ViteCaddyTlsPluginOptions): PluginOption;
|
|
44
|
+
declare function viteCaddyTlsPlugin({ domain, baseDomain, loopbackDomain, repo, branch, instanceLabel, cors, serverName, caddyApiUrl, caddyAdminOrigin, internalTls, upstreamHostHeader, }?: ViteCaddyTlsPluginOptions): PluginOption;
|
|
43
45
|
|
|
44
46
|
export { type ViteCaddyTlsPluginOptions, viteCaddyTlsPlugin as default };
|
package/dist/index.js
CHANGED
|
@@ -1,19 +1,88 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { execSync as execSync2 } from "child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
4
|
import path2 from "path";
|
|
5
5
|
|
|
6
6
|
// src/utils.ts
|
|
7
7
|
import { execSync } from "child_process";
|
|
8
8
|
import { createHash } from "crypto";
|
|
9
|
-
import { open, unlink } from "fs/promises";
|
|
9
|
+
import { mkdir, open, readFile, readdir, rename, unlink, writeFile } from "fs/promises";
|
|
10
10
|
import os from "os";
|
|
11
11
|
import path from "path";
|
|
12
12
|
var DEFAULT_SERVER_NAME = "srv0";
|
|
13
13
|
var DEFAULT_CADDY_API_URL = "http://localhost:2019";
|
|
14
|
+
var CADDY_ADMIN_ORIGIN_POLICY_ERROR_MESSAGE = "Caddy Admin API rejected request due to origin policy. Check caddyApiUrl and admin origin settings.";
|
|
15
|
+
var ROUTE_ID_PREFIX = "vite-proxy-";
|
|
16
|
+
var LOCK_TIMEOUT_MS = 5e3;
|
|
17
|
+
var LOCK_RETRY_MS = 50;
|
|
18
|
+
var ROUTE_OWNERSHIP_VERSION = 1;
|
|
19
|
+
var ROUTE_OWNERSHIP_STALE_AFTER_MS = 3e4;
|
|
20
|
+
var ROUTE_OWNERSHIP_HEARTBEAT_INTERVAL_MS = 1e4;
|
|
21
|
+
var CONNECTIVITY_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
22
|
+
"ECONNREFUSED",
|
|
23
|
+
"ECONNRESET",
|
|
24
|
+
"EHOSTUNREACH",
|
|
25
|
+
"ENETUNREACH",
|
|
26
|
+
"ENOTFOUND",
|
|
27
|
+
"ETIMEDOUT",
|
|
28
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
29
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
30
|
+
"UND_ERR_SOCKET"
|
|
31
|
+
]);
|
|
14
32
|
function isRecord(value) {
|
|
15
33
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
16
34
|
}
|
|
35
|
+
function normalizeRouteOwnershipDomains(domains) {
|
|
36
|
+
return Array.from(new Set(domains)).sort();
|
|
37
|
+
}
|
|
38
|
+
function getRouteOwnershipDirectory() {
|
|
39
|
+
return path.join(os.tmpdir(), "vite-plugin-caddy-multiple-tls", "owners");
|
|
40
|
+
}
|
|
41
|
+
function getRouteOwnershipPaths(scope) {
|
|
42
|
+
const key = createHash("sha1").update(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
domains: normalizeRouteOwnershipDomains(scope.domains),
|
|
45
|
+
serverName: scope.serverName,
|
|
46
|
+
caddyApiUrl: scope.caddyApiUrl
|
|
47
|
+
})
|
|
48
|
+
).digest("hex").slice(0, 20);
|
|
49
|
+
const scopeLockKey = createHash("sha1").update(
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
serverName: scope.serverName,
|
|
52
|
+
caddyApiUrl: scope.caddyApiUrl
|
|
53
|
+
})
|
|
54
|
+
).digest("hex").slice(0, 20);
|
|
55
|
+
const directory = getRouteOwnershipDirectory();
|
|
56
|
+
return {
|
|
57
|
+
directory,
|
|
58
|
+
recordPath: path.join(directory, `${key}.json`),
|
|
59
|
+
lockPath: path.join(directory, `scope-${scopeLockKey}.lock`)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function isRouteOwnershipRecord(value) {
|
|
63
|
+
if (!isRecord(value)) return false;
|
|
64
|
+
if (value.version !== ROUTE_OWNERSHIP_VERSION) return false;
|
|
65
|
+
if (typeof value.ownerId !== "string" || !value.ownerId) return false;
|
|
66
|
+
if (typeof value.pid !== "number" || !Number.isFinite(value.pid)) return false;
|
|
67
|
+
if (typeof value.cwd !== "string") return false;
|
|
68
|
+
if (value.configRoot !== null && typeof value.configRoot !== "string") return false;
|
|
69
|
+
if (!Array.isArray(value.domains) || value.domains.some((domain) => typeof domain !== "string")) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (typeof value.routeId !== "string" || !value.routeId) return false;
|
|
73
|
+
if (value.tlsPolicyId !== null && typeof value.tlsPolicyId !== "string") return false;
|
|
74
|
+
if (typeof value.serverName !== "string" || !value.serverName) return false;
|
|
75
|
+
if (typeof value.caddyApiUrl !== "string" || !value.caddyApiUrl) return false;
|
|
76
|
+
if (typeof value.startedAt !== "number" || !Number.isFinite(value.startedAt)) return false;
|
|
77
|
+
if (typeof value.lastSeenAt !== "number" || !Number.isFinite(value.lastSeenAt)) return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
function normalizeRouteOwnershipRecord(record) {
|
|
81
|
+
return {
|
|
82
|
+
...record,
|
|
83
|
+
domains: normalizeRouteOwnershipDomains(record.domains)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
17
86
|
function parseConfig(text) {
|
|
18
87
|
if (!text.trim()) return {};
|
|
19
88
|
try {
|
|
@@ -28,6 +97,93 @@ function isTlsPolicyOverlapError(text) {
|
|
|
28
97
|
function getApiUrl(apiUrl) {
|
|
29
98
|
return apiUrl ?? DEFAULT_CADDY_API_URL;
|
|
30
99
|
}
|
|
100
|
+
function toError(error) {
|
|
101
|
+
if (error instanceof Error) return error;
|
|
102
|
+
return new Error(String(error));
|
|
103
|
+
}
|
|
104
|
+
function isOriginPolicyError(status, text) {
|
|
105
|
+
if (status !== 403) return false;
|
|
106
|
+
const normalizedText = text.toLowerCase();
|
|
107
|
+
return normalizedText.includes("origin") && normalizedText.includes("not allowed");
|
|
108
|
+
}
|
|
109
|
+
function buildCaddyRequestError(message, status, text) {
|
|
110
|
+
if (isOriginPolicyError(status, text)) {
|
|
111
|
+
return new Error(CADDY_ADMIN_ORIGIN_POLICY_ERROR_MESSAGE);
|
|
112
|
+
}
|
|
113
|
+
const normalizedText = text.trim();
|
|
114
|
+
if (!normalizedText) {
|
|
115
|
+
return new Error(`${message}: HTTP ${status}`);
|
|
116
|
+
}
|
|
117
|
+
return new Error(`${message}: ${normalizedText}`);
|
|
118
|
+
}
|
|
119
|
+
function isNodeError(error) {
|
|
120
|
+
return Boolean(error) && typeof error === "object" && "code" in error;
|
|
121
|
+
}
|
|
122
|
+
function getErrorCode(error) {
|
|
123
|
+
if (!error || typeof error !== "object") return void 0;
|
|
124
|
+
if ("code" in error && typeof error.code === "string") {
|
|
125
|
+
return error.code;
|
|
126
|
+
}
|
|
127
|
+
if ("cause" in error) {
|
|
128
|
+
const cause = error.cause;
|
|
129
|
+
if (cause && typeof cause === "object") {
|
|
130
|
+
if ("code" in cause && typeof cause.code === "string") {
|
|
131
|
+
return cause.code;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return void 0;
|
|
136
|
+
}
|
|
137
|
+
function isConnectivityError(error) {
|
|
138
|
+
const code = getErrorCode(error);
|
|
139
|
+
return Boolean(code) && CONNECTIVITY_ERROR_CODES.has(code);
|
|
140
|
+
}
|
|
141
|
+
function getAdminOrigin(apiUrl, adminOrigin) {
|
|
142
|
+
const originSource = adminOrigin ?? getApiUrl(apiUrl);
|
|
143
|
+
try {
|
|
144
|
+
return new URL(originSource).origin;
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return new URL(getApiUrl(apiUrl)).origin;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function caddyFetch(input, init, apiUrl, adminOrigin) {
|
|
150
|
+
const headers = new Headers(init?.headers);
|
|
151
|
+
headers.set("Origin", getAdminOrigin(apiUrl, adminOrigin));
|
|
152
|
+
return fetch(input, {
|
|
153
|
+
...init,
|
|
154
|
+
headers
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
async function checkCaddyAdminStatus(apiUrl, adminOrigin) {
|
|
158
|
+
try {
|
|
159
|
+
const res = await caddyFetch(`${getApiUrl(apiUrl)}/config/`, void 0, apiUrl, adminOrigin);
|
|
160
|
+
if (res.ok) {
|
|
161
|
+
return { status: "running" };
|
|
162
|
+
}
|
|
163
|
+
const text = await res.text();
|
|
164
|
+
return {
|
|
165
|
+
status: "api-error",
|
|
166
|
+
error: buildCaddyRequestError("Failed to read Caddy config", res.status, text)
|
|
167
|
+
};
|
|
168
|
+
} catch (e) {
|
|
169
|
+
const error = toError(e);
|
|
170
|
+
if (isConnectivityError(error)) {
|
|
171
|
+
return {
|
|
172
|
+
status: "connectivity-error",
|
|
173
|
+
error
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
status: "api-error",
|
|
178
|
+
error
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function assertCaddyResponse(res, message) {
|
|
183
|
+
if (res.ok) return;
|
|
184
|
+
const text = await res.text();
|
|
185
|
+
throw buildCaddyRequestError(message, res.status, text);
|
|
186
|
+
}
|
|
31
187
|
function getLockPath(apiUrl) {
|
|
32
188
|
const key = createHash("sha1").update(getApiUrl(apiUrl)).digest("hex").slice(0, 12);
|
|
33
189
|
return path.join(os.tmpdir(), `vite-plugin-caddy-multiple-tls-${key}.lock`);
|
|
@@ -35,10 +191,9 @@ function getLockPath(apiUrl) {
|
|
|
35
191
|
function sleep(ms) {
|
|
36
192
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
193
|
}
|
|
38
|
-
async function
|
|
39
|
-
|
|
194
|
+
async function withFileLock(lockPath, fn) {
|
|
195
|
+
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
40
196
|
const startedAt = Date.now();
|
|
41
|
-
const timeoutMs = 5e3;
|
|
42
197
|
while (true) {
|
|
43
198
|
try {
|
|
44
199
|
const handle = await open(lockPath, "wx");
|
|
@@ -50,50 +205,196 @@ async function withApiLock(apiUrl, fn) {
|
|
|
50
205
|
}
|
|
51
206
|
return;
|
|
52
207
|
} catch (e) {
|
|
53
|
-
if (e.code !== "EEXIST") {
|
|
208
|
+
if (!isNodeError(e) || e.code !== "EEXIST") {
|
|
54
209
|
throw e;
|
|
55
210
|
}
|
|
56
|
-
if (Date.now() - startedAt >=
|
|
211
|
+
if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
|
|
57
212
|
await fn();
|
|
58
213
|
return;
|
|
59
214
|
}
|
|
60
|
-
await sleep(
|
|
215
|
+
await sleep(LOCK_RETRY_MS);
|
|
61
216
|
}
|
|
62
217
|
}
|
|
63
218
|
}
|
|
64
|
-
function
|
|
219
|
+
async function withApiLock(apiUrl, fn) {
|
|
220
|
+
await withFileLock(getLockPath(apiUrl), fn);
|
|
221
|
+
}
|
|
222
|
+
async function readRouteOwnershipByPath(recordPath) {
|
|
65
223
|
try {
|
|
66
|
-
|
|
224
|
+
const text = await readFile(recordPath, "utf8");
|
|
225
|
+
const parsed = parseConfig(text);
|
|
226
|
+
if (!isRouteOwnershipRecord(parsed)) return null;
|
|
227
|
+
return normalizeRouteOwnershipRecord(parsed);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
if (isNodeError(e) && e.code === "ENOENT") {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async function writeRouteOwnership(record) {
|
|
236
|
+
const normalizedRecord = normalizeRouteOwnershipRecord(record);
|
|
237
|
+
const { directory, recordPath } = getRouteOwnershipPaths(normalizedRecord);
|
|
238
|
+
const tempPath = path.join(
|
|
239
|
+
directory,
|
|
240
|
+
`${path.basename(recordPath)}.${process.pid}.${Date.now()}.tmp`
|
|
241
|
+
);
|
|
242
|
+
await mkdir(directory, { recursive: true });
|
|
243
|
+
await writeFile(tempPath, JSON.stringify(normalizedRecord), "utf8");
|
|
244
|
+
await rename(tempPath, recordPath);
|
|
245
|
+
}
|
|
246
|
+
async function listRouteOwnershipRecords(scope) {
|
|
247
|
+
const directory = getRouteOwnershipDirectory();
|
|
248
|
+
let entries;
|
|
249
|
+
try {
|
|
250
|
+
entries = await readdir(directory);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
if (isNodeError(e) && e.code === "ENOENT") {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
throw e;
|
|
256
|
+
}
|
|
257
|
+
const records = await Promise.all(
|
|
258
|
+
entries.filter((entry) => entry.endsWith(".json")).map((entry) => readRouteOwnershipByPath(path.join(directory, entry)))
|
|
259
|
+
);
|
|
260
|
+
return records.filter((record) => {
|
|
261
|
+
return Boolean(
|
|
262
|
+
record && record.serverName === scope.serverName && record.caddyApiUrl === scope.caddyApiUrl
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function isProcessAlive(pid) {
|
|
267
|
+
try {
|
|
268
|
+
process.kill(pid, 0);
|
|
67
269
|
return true;
|
|
68
270
|
} catch (e) {
|
|
69
|
-
|
|
70
|
-
|
|
271
|
+
return isNodeError(e) && e.code === "EPERM";
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function isRouteOwnershipActive(record, now = Date.now()) {
|
|
275
|
+
return isProcessAlive(record.pid) || now - record.lastSeenAt <= ROUTE_OWNERSHIP_STALE_AFTER_MS;
|
|
276
|
+
}
|
|
277
|
+
async function claimRouteOwnership(record) {
|
|
278
|
+
const normalizedRecord = normalizeRouteOwnershipRecord(record);
|
|
279
|
+
const { lockPath, recordPath } = getRouteOwnershipPaths(normalizedRecord);
|
|
280
|
+
let claimResult = null;
|
|
281
|
+
await withFileLock(lockPath, async () => {
|
|
282
|
+
const existingRecord = await readRouteOwnershipByPath(recordPath);
|
|
283
|
+
if (existingRecord?.ownerId === normalizedRecord.ownerId) {
|
|
284
|
+
await writeRouteOwnership(normalizedRecord);
|
|
285
|
+
claimResult = {
|
|
286
|
+
status: "claimed",
|
|
287
|
+
currentRecord: normalizedRecord
|
|
288
|
+
};
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const overlappingRecords = (await listRouteOwnershipRecords(normalizedRecord)).filter(
|
|
292
|
+
(candidate) => {
|
|
293
|
+
return candidate.ownerId !== normalizedRecord.ownerId && intersectsDomains(candidate.domains, normalizedRecord.domains);
|
|
294
|
+
}
|
|
295
|
+
);
|
|
296
|
+
const activeConflict = overlappingRecords.find((candidate) => {
|
|
297
|
+
return isRouteOwnershipActive(candidate);
|
|
298
|
+
});
|
|
299
|
+
if (activeConflict) {
|
|
300
|
+
claimResult = {
|
|
301
|
+
status: "active-conflict",
|
|
302
|
+
currentRecord: normalizedRecord,
|
|
303
|
+
existingRecord: activeConflict
|
|
304
|
+
};
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
await writeRouteOwnership(normalizedRecord);
|
|
308
|
+
if (overlappingRecords.length > 0) {
|
|
309
|
+
claimResult = {
|
|
310
|
+
status: "reclaimed",
|
|
311
|
+
currentRecord: normalizedRecord,
|
|
312
|
+
previousRecords: overlappingRecords
|
|
313
|
+
};
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
claimResult = {
|
|
317
|
+
status: "claimed",
|
|
318
|
+
currentRecord: normalizedRecord
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
if (!claimResult) {
|
|
322
|
+
throw new Error("Failed to claim route ownership.");
|
|
71
323
|
}
|
|
324
|
+
return claimResult;
|
|
325
|
+
}
|
|
326
|
+
async function touchRouteOwnership(reference) {
|
|
327
|
+
const { lockPath, recordPath } = getRouteOwnershipPaths(reference);
|
|
328
|
+
let touched = false;
|
|
329
|
+
await withFileLock(lockPath, async () => {
|
|
330
|
+
const existingRecord = await readRouteOwnershipByPath(recordPath);
|
|
331
|
+
if (!existingRecord || existingRecord.ownerId !== reference.ownerId) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
await writeRouteOwnership({
|
|
335
|
+
...existingRecord,
|
|
336
|
+
lastSeenAt: Date.now()
|
|
337
|
+
});
|
|
338
|
+
touched = true;
|
|
339
|
+
});
|
|
340
|
+
return touched;
|
|
341
|
+
}
|
|
342
|
+
async function releaseRouteOwnership(reference) {
|
|
343
|
+
const { lockPath, recordPath } = getRouteOwnershipPaths(reference);
|
|
344
|
+
let released = false;
|
|
345
|
+
await withFileLock(lockPath, async () => {
|
|
346
|
+
const existingRecord = await readRouteOwnershipByPath(recordPath);
|
|
347
|
+
if (!existingRecord || existingRecord.ownerId !== reference.ownerId) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
await unlink(recordPath).catch((error) => {
|
|
351
|
+
if (!isNodeError(error) || error.code !== "ENOENT") {
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
released = true;
|
|
356
|
+
});
|
|
357
|
+
return released;
|
|
72
358
|
}
|
|
73
|
-
|
|
359
|
+
function validateCaddyIsInstalled() {
|
|
74
360
|
try {
|
|
75
|
-
|
|
76
|
-
return
|
|
361
|
+
execSync("caddy version");
|
|
362
|
+
return true;
|
|
77
363
|
} catch (e) {
|
|
364
|
+
console.error("caddy cli is not installed");
|
|
78
365
|
return false;
|
|
79
366
|
}
|
|
80
367
|
}
|
|
81
|
-
async function startCaddy(apiUrl) {
|
|
368
|
+
async function startCaddy(apiUrl, adminOrigin) {
|
|
82
369
|
try {
|
|
83
370
|
execSync("caddy start", { stdio: "ignore" });
|
|
84
371
|
} catch (e) {
|
|
85
372
|
}
|
|
86
373
|
for (let i = 0; i < 10; i++) {
|
|
87
|
-
|
|
374
|
+
const status = await checkCaddyAdminStatus(apiUrl, adminOrigin);
|
|
375
|
+
if (status.status === "running") return true;
|
|
376
|
+
if (status.status === "api-error") {
|
|
377
|
+
throw status.error;
|
|
378
|
+
}
|
|
88
379
|
await sleep(500);
|
|
89
380
|
}
|
|
90
381
|
return false;
|
|
91
382
|
}
|
|
92
|
-
async function ensureBaseConfig(serverName = DEFAULT_SERVER_NAME, apiUrl) {
|
|
383
|
+
async function ensureBaseConfig(serverName = DEFAULT_SERVER_NAME, apiUrl, adminOrigin) {
|
|
93
384
|
const resolvedApiUrl = getApiUrl(apiUrl);
|
|
94
385
|
const serverUrl = `${resolvedApiUrl}/config/apps/http/servers/${serverName}`;
|
|
95
|
-
const res = await
|
|
386
|
+
const res = await caddyFetch(serverUrl, void 0, apiUrl, adminOrigin);
|
|
96
387
|
if (res.ok) return;
|
|
388
|
+
if (res.status === 403) {
|
|
389
|
+
const text = await res.text();
|
|
390
|
+
if (isOriginPolicyError(res.status, text)) {
|
|
391
|
+
throw buildCaddyRequestError(
|
|
392
|
+
"Failed to initialize Caddy base configuration",
|
|
393
|
+
res.status,
|
|
394
|
+
text
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
97
398
|
const baseConfig = {
|
|
98
399
|
listen: [":443"],
|
|
99
400
|
routes: []
|
|
@@ -103,11 +404,13 @@ async function ensureBaseConfig(serverName = DEFAULT_SERVER_NAME, apiUrl) {
|
|
|
103
404
|
[serverName]: baseConfig
|
|
104
405
|
}
|
|
105
406
|
};
|
|
106
|
-
const configRes = await
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
407
|
+
const configRes = await caddyFetch(
|
|
408
|
+
`${resolvedApiUrl}/config/`,
|
|
409
|
+
void 0,
|
|
410
|
+
apiUrl,
|
|
411
|
+
adminOrigin
|
|
412
|
+
);
|
|
413
|
+
await assertCaddyResponse(configRes, "Failed to read Caddy config");
|
|
111
414
|
const configText = await configRes.text();
|
|
112
415
|
const config = parseConfig(configText);
|
|
113
416
|
if (config === void 0) {
|
|
@@ -115,18 +418,27 @@ async function ensureBaseConfig(serverName = DEFAULT_SERVER_NAME, apiUrl) {
|
|
|
115
418
|
}
|
|
116
419
|
const isEmptyConfig = configText.trim() === "" || config === null || isRecord(config) && Object.keys(config).length === 0;
|
|
117
420
|
if (isEmptyConfig) {
|
|
118
|
-
const loadRes = await
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
421
|
+
const loadRes = await caddyFetch(
|
|
422
|
+
`${resolvedApiUrl}/load`,
|
|
423
|
+
{
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: { "Content-Type": "application/json" },
|
|
426
|
+
body: JSON.stringify({
|
|
427
|
+
apps: {
|
|
428
|
+
http: httpAppConfig
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
},
|
|
432
|
+
apiUrl,
|
|
433
|
+
adminOrigin
|
|
434
|
+
);
|
|
127
435
|
if (!loadRes.ok) {
|
|
128
436
|
const text = await loadRes.text();
|
|
129
|
-
throw
|
|
437
|
+
throw buildCaddyRequestError(
|
|
438
|
+
"Failed to initialize Caddy base configuration",
|
|
439
|
+
loadRes.status,
|
|
440
|
+
text
|
|
441
|
+
);
|
|
130
442
|
}
|
|
131
443
|
return;
|
|
132
444
|
}
|
|
@@ -137,82 +449,136 @@ async function ensureBaseConfig(serverName = DEFAULT_SERVER_NAME, apiUrl) {
|
|
|
137
449
|
let hasHttp = isRecord(http);
|
|
138
450
|
let hasServers = isRecord(servers);
|
|
139
451
|
if (!hasApps) {
|
|
140
|
-
const createAppsRes = await
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
452
|
+
const createAppsRes = await caddyFetch(
|
|
453
|
+
`${resolvedApiUrl}/config/apps`,
|
|
454
|
+
{
|
|
455
|
+
method: "PUT",
|
|
456
|
+
headers: { "Content-Type": "application/json" },
|
|
457
|
+
body: JSON.stringify({})
|
|
458
|
+
},
|
|
459
|
+
apiUrl,
|
|
460
|
+
adminOrigin
|
|
461
|
+
);
|
|
145
462
|
if (!createAppsRes.ok && createAppsRes.status !== 409) {
|
|
146
463
|
const text = await createAppsRes.text();
|
|
147
|
-
throw
|
|
464
|
+
throw buildCaddyRequestError(
|
|
465
|
+
"Failed to initialize Caddy base configuration",
|
|
466
|
+
createAppsRes.status,
|
|
467
|
+
text
|
|
468
|
+
);
|
|
148
469
|
}
|
|
149
470
|
hasApps = true;
|
|
150
471
|
}
|
|
151
472
|
if (!hasHttp) {
|
|
152
|
-
const createHttpRes = await
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
473
|
+
const createHttpRes = await caddyFetch(
|
|
474
|
+
`${resolvedApiUrl}/config/apps/http`,
|
|
475
|
+
{
|
|
476
|
+
method: "PUT",
|
|
477
|
+
headers: { "Content-Type": "application/json" },
|
|
478
|
+
body: JSON.stringify({ servers: {} })
|
|
479
|
+
},
|
|
480
|
+
apiUrl,
|
|
481
|
+
adminOrigin
|
|
482
|
+
);
|
|
157
483
|
if (!createHttpRes.ok && createHttpRes.status !== 409) {
|
|
158
484
|
const text = await createHttpRes.text();
|
|
159
|
-
throw
|
|
485
|
+
throw buildCaddyRequestError(
|
|
486
|
+
"Failed to initialize Caddy base configuration",
|
|
487
|
+
createHttpRes.status,
|
|
488
|
+
text
|
|
489
|
+
);
|
|
160
490
|
}
|
|
161
491
|
hasHttp = true;
|
|
162
492
|
hasServers = true;
|
|
163
493
|
}
|
|
164
494
|
if (!hasServers) {
|
|
165
|
-
const createServersRes = await
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
495
|
+
const createServersRes = await caddyFetch(
|
|
496
|
+
`${resolvedApiUrl}/config/apps/http/servers`,
|
|
497
|
+
{
|
|
498
|
+
method: "PUT",
|
|
499
|
+
headers: { "Content-Type": "application/json" },
|
|
500
|
+
body: JSON.stringify({})
|
|
501
|
+
},
|
|
502
|
+
apiUrl,
|
|
503
|
+
adminOrigin
|
|
504
|
+
);
|
|
170
505
|
if (!createServersRes.ok && createServersRes.status !== 409) {
|
|
171
506
|
const text = await createServersRes.text();
|
|
172
|
-
throw
|
|
507
|
+
throw buildCaddyRequestError(
|
|
508
|
+
"Failed to initialize Caddy base configuration",
|
|
509
|
+
createServersRes.status,
|
|
510
|
+
text
|
|
511
|
+
);
|
|
173
512
|
}
|
|
174
513
|
}
|
|
175
|
-
const createServerRes = await
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
514
|
+
const createServerRes = await caddyFetch(
|
|
515
|
+
serverUrl,
|
|
516
|
+
{
|
|
517
|
+
method: "PUT",
|
|
518
|
+
headers: { "Content-Type": "application/json" },
|
|
519
|
+
body: JSON.stringify(baseConfig)
|
|
520
|
+
},
|
|
521
|
+
apiUrl,
|
|
522
|
+
adminOrigin
|
|
523
|
+
);
|
|
180
524
|
if (!createServerRes.ok && createServerRes.status !== 409) {
|
|
181
525
|
const text = await createServerRes.text();
|
|
182
|
-
throw
|
|
526
|
+
throw buildCaddyRequestError(
|
|
527
|
+
"Failed to initialize Caddy base configuration",
|
|
528
|
+
createServerRes.status,
|
|
529
|
+
text
|
|
530
|
+
);
|
|
183
531
|
}
|
|
184
532
|
}
|
|
185
|
-
async function ensureTlsAutomation(apiUrl) {
|
|
533
|
+
async function ensureTlsAutomation(apiUrl, adminOrigin) {
|
|
186
534
|
const resolvedApiUrl = getApiUrl(apiUrl);
|
|
187
535
|
const policiesUrl = `${resolvedApiUrl}/config/apps/tls/automation/policies`;
|
|
188
|
-
const policiesRes = await
|
|
536
|
+
const policiesRes = await caddyFetch(policiesUrl, void 0, apiUrl, adminOrigin);
|
|
189
537
|
if (policiesRes.ok) return;
|
|
190
538
|
const policiesText = await policiesRes.text();
|
|
191
539
|
if (policiesRes.status !== 404 && !policiesText.includes("invalid traversal path")) {
|
|
192
|
-
throw
|
|
193
|
-
|
|
540
|
+
throw buildCaddyRequestError(
|
|
541
|
+
"Failed to initialize Caddy TLS automation",
|
|
542
|
+
policiesRes.status,
|
|
543
|
+
policiesText
|
|
194
544
|
);
|
|
195
545
|
}
|
|
196
|
-
const automationRes = await
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
546
|
+
const automationRes = await caddyFetch(
|
|
547
|
+
`${resolvedApiUrl}/config/apps/tls/automation`,
|
|
548
|
+
{
|
|
549
|
+
method: "PUT",
|
|
550
|
+
headers: { "Content-Type": "application/json" },
|
|
551
|
+
body: JSON.stringify({ policies: [] })
|
|
552
|
+
},
|
|
553
|
+
apiUrl,
|
|
554
|
+
adminOrigin
|
|
555
|
+
);
|
|
201
556
|
if (automationRes.ok || automationRes.status === 409) return;
|
|
202
557
|
const automationText = await automationRes.text();
|
|
203
558
|
if (!automationText.includes("invalid traversal path")) {
|
|
204
|
-
throw
|
|
205
|
-
|
|
559
|
+
throw buildCaddyRequestError(
|
|
560
|
+
"Failed to initialize Caddy TLS automation",
|
|
561
|
+
automationRes.status,
|
|
562
|
+
automationText
|
|
206
563
|
);
|
|
207
564
|
}
|
|
208
|
-
const tlsRes = await
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
565
|
+
const tlsRes = await caddyFetch(
|
|
566
|
+
`${resolvedApiUrl}/config/apps/tls`,
|
|
567
|
+
{
|
|
568
|
+
method: "PUT",
|
|
569
|
+
headers: { "Content-Type": "application/json" },
|
|
570
|
+
body: JSON.stringify({ automation: { policies: [] } })
|
|
571
|
+
},
|
|
572
|
+
apiUrl,
|
|
573
|
+
adminOrigin
|
|
574
|
+
);
|
|
213
575
|
if (!tlsRes.ok && tlsRes.status !== 409) {
|
|
214
576
|
const text = await tlsRes.text();
|
|
215
|
-
throw
|
|
577
|
+
throw buildCaddyRequestError(
|
|
578
|
+
"Failed to initialize Caddy TLS automation",
|
|
579
|
+
tlsRes.status,
|
|
580
|
+
text
|
|
581
|
+
);
|
|
216
582
|
}
|
|
217
583
|
}
|
|
218
584
|
function formatDialAddress(host, port) {
|
|
@@ -236,32 +602,70 @@ function extractMatchedHosts(route) {
|
|
|
236
602
|
}
|
|
237
603
|
return hosts;
|
|
238
604
|
}
|
|
605
|
+
function extractMatchedSubjects(policy) {
|
|
606
|
+
if (!isRecord(policy) || !Array.isArray(policy.subjects)) return [];
|
|
607
|
+
const subjects = [];
|
|
608
|
+
for (const subject of policy.subjects) {
|
|
609
|
+
if (typeof subject === "string") {
|
|
610
|
+
subjects.push(subject);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return subjects;
|
|
614
|
+
}
|
|
239
615
|
function intersectsDomains(targetDomains, routeDomains) {
|
|
240
616
|
if (targetDomains.length === 0 || routeDomains.length === 0) return false;
|
|
241
617
|
const targetSet = new Set(targetDomains);
|
|
242
618
|
return routeDomains.some((domain) => targetSet.has(domain));
|
|
243
619
|
}
|
|
244
|
-
async function
|
|
245
|
-
if (domains.length === 0) return;
|
|
246
|
-
const res = await
|
|
247
|
-
`${getApiUrl(apiUrl)}/config/apps/http/servers/${serverName}/routes
|
|
620
|
+
async function findManagedRoutesForDomains(domains, serverName = DEFAULT_SERVER_NAME, apiUrl, adminOrigin) {
|
|
621
|
+
if (domains.length === 0) return [];
|
|
622
|
+
const res = await caddyFetch(
|
|
623
|
+
`${getApiUrl(apiUrl)}/config/apps/http/servers/${serverName}/routes`,
|
|
624
|
+
void 0,
|
|
625
|
+
apiUrl,
|
|
626
|
+
adminOrigin
|
|
248
627
|
);
|
|
249
|
-
if (!res.ok) return;
|
|
628
|
+
if (!res.ok) return [];
|
|
250
629
|
const text = await res.text();
|
|
251
630
|
const parsed = parseConfig(text);
|
|
252
|
-
if (!Array.isArray(parsed)) return;
|
|
631
|
+
if (!Array.isArray(parsed)) return [];
|
|
632
|
+
const routeIds = [];
|
|
253
633
|
for (const route of parsed) {
|
|
254
634
|
if (!isRecord(route)) continue;
|
|
255
635
|
const id = route["@id"];
|
|
256
636
|
if (typeof id !== "string") continue;
|
|
257
|
-
if (!id.startsWith(
|
|
258
|
-
if (id === currentRouteId) continue;
|
|
637
|
+
if (!id.startsWith(ROUTE_ID_PREFIX)) continue;
|
|
259
638
|
const routeDomains = extractMatchedHosts(route);
|
|
260
639
|
if (!intersectsDomains(domains, routeDomains)) continue;
|
|
261
|
-
|
|
640
|
+
routeIds.push(id);
|
|
262
641
|
}
|
|
642
|
+
return routeIds;
|
|
263
643
|
}
|
|
264
|
-
async function
|
|
644
|
+
async function findManagedTlsPoliciesForDomains(domains, apiUrl, adminOrigin) {
|
|
645
|
+
if (domains.length === 0) return [];
|
|
646
|
+
const res = await caddyFetch(
|
|
647
|
+
`${getApiUrl(apiUrl)}/config/apps/tls/automation/policies`,
|
|
648
|
+
void 0,
|
|
649
|
+
apiUrl,
|
|
650
|
+
adminOrigin
|
|
651
|
+
);
|
|
652
|
+
if (!res.ok) return [];
|
|
653
|
+
const text = await res.text();
|
|
654
|
+
const parsed = parseConfig(text);
|
|
655
|
+
if (!Array.isArray(parsed)) return [];
|
|
656
|
+
const policyIds = [];
|
|
657
|
+
for (const policy of parsed) {
|
|
658
|
+
if (!isRecord(policy)) continue;
|
|
659
|
+
const id = policy["@id"];
|
|
660
|
+
if (typeof id !== "string") continue;
|
|
661
|
+
if (!id.startsWith(ROUTE_ID_PREFIX)) continue;
|
|
662
|
+
const policyDomains = extractMatchedSubjects(policy);
|
|
663
|
+
if (!intersectsDomains(domains, policyDomains)) continue;
|
|
664
|
+
policyIds.push(id);
|
|
665
|
+
}
|
|
666
|
+
return policyIds;
|
|
667
|
+
}
|
|
668
|
+
async function addRoute(id, domains, port, cors, serverName = DEFAULT_SERVER_NAME, upstreamHost = "127.0.0.1", upstreamHostHeader, apiUrl, adminOrigin) {
|
|
265
669
|
const handlers = [];
|
|
266
670
|
if (cors) {
|
|
267
671
|
handlers.push({
|
|
@@ -311,22 +715,24 @@ async function addRoute(id, domains, port, cors, serverName = DEFAULT_SERVER_NAM
|
|
|
311
715
|
],
|
|
312
716
|
terminal: true
|
|
313
717
|
};
|
|
314
|
-
const res = await
|
|
718
|
+
const res = await caddyFetch(
|
|
315
719
|
`${getApiUrl(apiUrl)}/config/apps/http/servers/${serverName}/routes`,
|
|
316
720
|
{
|
|
317
721
|
method: "POST",
|
|
318
722
|
// Append to routes list
|
|
319
723
|
headers: { "Content-Type": "application/json" },
|
|
320
724
|
body: JSON.stringify(route)
|
|
321
|
-
}
|
|
725
|
+
},
|
|
726
|
+
apiUrl,
|
|
727
|
+
adminOrigin
|
|
322
728
|
);
|
|
323
729
|
if (!res.ok) {
|
|
324
730
|
const text = await res.text();
|
|
325
|
-
throw
|
|
731
|
+
throw buildCaddyRequestError("Failed to add route", res.status, text);
|
|
326
732
|
}
|
|
327
733
|
}
|
|
328
|
-
async function addTlsPolicy(id, domains, apiUrl) {
|
|
329
|
-
await ensureTlsAutomation(apiUrl);
|
|
734
|
+
async function addTlsPolicy(id, domains, apiUrl, adminOrigin) {
|
|
735
|
+
await ensureTlsAutomation(apiUrl, adminOrigin);
|
|
330
736
|
const policy = {
|
|
331
737
|
"@id": id,
|
|
332
738
|
subjects: domains,
|
|
@@ -336,49 +742,76 @@ async function addTlsPolicy(id, domains, apiUrl) {
|
|
|
336
742
|
}
|
|
337
743
|
]
|
|
338
744
|
};
|
|
339
|
-
const res = await
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
745
|
+
const res = await caddyFetch(
|
|
746
|
+
`${getApiUrl(apiUrl)}/config/apps/tls/automation/policies`,
|
|
747
|
+
{
|
|
748
|
+
method: "POST",
|
|
749
|
+
headers: { "Content-Type": "application/json" },
|
|
750
|
+
body: JSON.stringify(policy)
|
|
751
|
+
},
|
|
752
|
+
apiUrl,
|
|
753
|
+
adminOrigin
|
|
754
|
+
);
|
|
344
755
|
if (!res.ok) {
|
|
345
756
|
const text = await res.text();
|
|
346
757
|
if (isTlsPolicyOverlapError(text)) {
|
|
347
758
|
return;
|
|
348
759
|
}
|
|
349
|
-
throw
|
|
760
|
+
throw buildCaddyRequestError("Failed to add TLS policy", res.status, text);
|
|
350
761
|
}
|
|
351
762
|
}
|
|
352
|
-
async function removeRoute(id, apiUrl) {
|
|
353
|
-
const res = await
|
|
354
|
-
|
|
355
|
-
|
|
763
|
+
async function removeRoute(id, apiUrl, adminOrigin) {
|
|
764
|
+
const res = await caddyFetch(
|
|
765
|
+
`${getApiUrl(apiUrl)}/id/${id}`,
|
|
766
|
+
{
|
|
767
|
+
method: "DELETE"
|
|
768
|
+
},
|
|
769
|
+
apiUrl,
|
|
770
|
+
adminOrigin
|
|
771
|
+
);
|
|
356
772
|
if (!res.ok && res.status !== 404) {
|
|
357
|
-
|
|
773
|
+
const text = await res.text();
|
|
774
|
+
const error = buildCaddyRequestError(`Failed to remove route ${id}`, res.status, text);
|
|
775
|
+
console.error(error.message);
|
|
358
776
|
return false;
|
|
359
777
|
}
|
|
360
778
|
return true;
|
|
361
779
|
}
|
|
362
|
-
async function removeTlsPolicy(id, apiUrl) {
|
|
363
|
-
const res = await
|
|
364
|
-
|
|
365
|
-
|
|
780
|
+
async function removeTlsPolicy(id, apiUrl, adminOrigin) {
|
|
781
|
+
const res = await caddyFetch(
|
|
782
|
+
`${getApiUrl(apiUrl)}/id/${id}`,
|
|
783
|
+
{
|
|
784
|
+
method: "DELETE"
|
|
785
|
+
},
|
|
786
|
+
apiUrl,
|
|
787
|
+
adminOrigin
|
|
788
|
+
);
|
|
366
789
|
if (!res.ok && res.status !== 404) {
|
|
367
|
-
|
|
790
|
+
const text = await res.text();
|
|
791
|
+
const error = buildCaddyRequestError(
|
|
792
|
+
`Failed to remove TLS policy ${id}`,
|
|
793
|
+
res.status,
|
|
794
|
+
text
|
|
795
|
+
);
|
|
796
|
+
console.error(error.message);
|
|
368
797
|
return false;
|
|
369
798
|
}
|
|
370
799
|
return true;
|
|
371
800
|
}
|
|
372
|
-
async function ensureCaddyReady(serverName = DEFAULT_SERVER_NAME, apiUrl) {
|
|
801
|
+
async function ensureCaddyReady(serverName = DEFAULT_SERVER_NAME, apiUrl, adminOrigin) {
|
|
373
802
|
await withApiLock(apiUrl, async () => {
|
|
374
|
-
|
|
375
|
-
if (
|
|
376
|
-
|
|
803
|
+
const status = await checkCaddyAdminStatus(apiUrl, adminOrigin);
|
|
804
|
+
if (status.status === "api-error") {
|
|
805
|
+
throw status.error;
|
|
806
|
+
}
|
|
807
|
+
let running = status.status === "running";
|
|
808
|
+
if (status.status === "connectivity-error") {
|
|
809
|
+
running = await startCaddy(apiUrl, adminOrigin);
|
|
377
810
|
}
|
|
378
811
|
if (!running) {
|
|
379
812
|
throw new Error("Failed to start Caddy server.");
|
|
380
813
|
}
|
|
381
|
-
await ensureBaseConfig(serverName, apiUrl);
|
|
814
|
+
await ensureBaseConfig(serverName, apiUrl, adminOrigin);
|
|
382
815
|
});
|
|
383
816
|
}
|
|
384
817
|
|
|
@@ -474,6 +907,15 @@ function normalizeCaddyApiUrl(url) {
|
|
|
474
907
|
if (!trimmed) return null;
|
|
475
908
|
return trimmed.replace(/\/+$/g, "");
|
|
476
909
|
}
|
|
910
|
+
function normalizeCaddyAdminOrigin(origin) {
|
|
911
|
+
const trimmed = origin.trim();
|
|
912
|
+
if (!trimmed) return null;
|
|
913
|
+
try {
|
|
914
|
+
return new URL(trimmed).origin;
|
|
915
|
+
} catch (e) {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
477
919
|
function resolveDomains(options) {
|
|
478
920
|
if (options.domain) {
|
|
479
921
|
return normalizeDomains(options.domain);
|
|
@@ -492,23 +934,100 @@ function viteCaddyTlsPlugin({
|
|
|
492
934
|
cors,
|
|
493
935
|
serverName,
|
|
494
936
|
caddyApiUrl,
|
|
937
|
+
caddyAdminOrigin,
|
|
495
938
|
internalTls,
|
|
496
939
|
upstreamHostHeader
|
|
497
940
|
} = {}) {
|
|
498
941
|
const normalizedApiUrl = caddyApiUrl ? normalizeCaddyApiUrl(caddyApiUrl) : null;
|
|
499
942
|
const pluginCaddyApiUrl = normalizedApiUrl ?? DEFAULT_CADDY_API_URL;
|
|
943
|
+
const normalizedAdminOrigin = caddyAdminOrigin ? normalizeCaddyAdminOrigin(caddyAdminOrigin) : null;
|
|
944
|
+
const pluginCaddyAdminOrigin = normalizedAdminOrigin ?? pluginCaddyApiUrl;
|
|
500
945
|
if (caddyApiUrl !== void 0 && !normalizedApiUrl) {
|
|
501
946
|
console.warn(
|
|
502
947
|
`caddyApiUrl is empty after trimming. Falling back to ${DEFAULT_CADDY_API_URL}.`
|
|
503
948
|
);
|
|
504
949
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
950
|
+
if (caddyAdminOrigin !== void 0 && !normalizedAdminOrigin) {
|
|
951
|
+
console.warn(
|
|
952
|
+
`caddyAdminOrigin is invalid. Falling back to ${pluginCaddyApiUrl}.`
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
function createOwnerId() {
|
|
956
|
+
return `${process.pid}-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
957
|
+
}
|
|
958
|
+
function createRouteOwnershipRecord(ownerId, domains, configRoot) {
|
|
959
|
+
const routeId = `vite-proxy-${ownerId}`;
|
|
960
|
+
const shouldUseInternalTls = internalTls ?? (baseDomain !== void 0 || loopbackDomain !== void 0 || domain !== void 0);
|
|
961
|
+
const now = Date.now();
|
|
962
|
+
return {
|
|
963
|
+
version: 1,
|
|
964
|
+
ownerId,
|
|
965
|
+
pid: process.pid,
|
|
508
966
|
cwd: process.cwd(),
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
967
|
+
configRoot: configRoot ?? null,
|
|
968
|
+
domains: [...domains],
|
|
969
|
+
routeId,
|
|
970
|
+
tlsPolicyId: shouldUseInternalTls ? `${routeId}-tls` : null,
|
|
971
|
+
serverName: serverName ?? "srv0",
|
|
972
|
+
caddyApiUrl: pluginCaddyApiUrl,
|
|
973
|
+
startedAt: now,
|
|
974
|
+
lastSeenAt: now
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
function buildOwnershipConflictMessage(domains, existingRecord) {
|
|
978
|
+
const ownerLocation = existingRecord.configRoot ?? existingRecord.cwd;
|
|
979
|
+
const domainLabel = domains.join(", ");
|
|
980
|
+
return [
|
|
981
|
+
`Cannot claim ${domainLabel}: another Vite server already owns ${domains.length > 1 ? "these domains" : "this domain"}.`,
|
|
982
|
+
`Existing owner pid ${existingRecord.pid} from ${ownerLocation}.`,
|
|
983
|
+
"Stop the other server first, or use `instanceLabel` or `domain` to make the hostname unique."
|
|
984
|
+
].join(" ");
|
|
985
|
+
}
|
|
986
|
+
async function releaseOwnershipRecord(record) {
|
|
987
|
+
if (!record) return;
|
|
988
|
+
await releaseRouteOwnership(record);
|
|
989
|
+
}
|
|
990
|
+
async function releaseOwnershipRecords(records) {
|
|
991
|
+
await Promise.all(records.map((record) => releaseOwnershipRecord(record)));
|
|
992
|
+
}
|
|
993
|
+
async function cleanupClaimedResources(record, removeWithRetry) {
|
|
994
|
+
let cleaned = true;
|
|
995
|
+
if (record.tlsPolicyId) {
|
|
996
|
+
const tlsPolicyId = record.tlsPolicyId;
|
|
997
|
+
cleaned = await removeWithRetry(
|
|
998
|
+
() => removeTlsPolicy(
|
|
999
|
+
tlsPolicyId,
|
|
1000
|
+
pluginCaddyApiUrl,
|
|
1001
|
+
pluginCaddyAdminOrigin
|
|
1002
|
+
),
|
|
1003
|
+
"TLS policy"
|
|
1004
|
+
) && cleaned;
|
|
1005
|
+
}
|
|
1006
|
+
cleaned = await removeWithRetry(
|
|
1007
|
+
() => removeRoute(record.routeId, pluginCaddyApiUrl, pluginCaddyAdminOrigin),
|
|
1008
|
+
"route"
|
|
1009
|
+
) && cleaned;
|
|
1010
|
+
return cleaned;
|
|
1011
|
+
}
|
|
1012
|
+
async function cleanupManagedResources(routeIds, tlsPolicyIds, removeWithRetry) {
|
|
1013
|
+
let cleaned = true;
|
|
1014
|
+
for (const managedTlsPolicyId of tlsPolicyIds) {
|
|
1015
|
+
cleaned = await removeWithRetry(
|
|
1016
|
+
() => removeTlsPolicy(
|
|
1017
|
+
managedTlsPolicyId,
|
|
1018
|
+
pluginCaddyApiUrl,
|
|
1019
|
+
pluginCaddyAdminOrigin
|
|
1020
|
+
),
|
|
1021
|
+
`managed TLS policy ${managedTlsPolicyId}`
|
|
1022
|
+
) && cleaned;
|
|
1023
|
+
}
|
|
1024
|
+
for (const managedRouteId of routeIds) {
|
|
1025
|
+
cleaned = await removeWithRetry(
|
|
1026
|
+
() => removeRoute(managedRouteId, pluginCaddyApiUrl, pluginCaddyAdminOrigin),
|
|
1027
|
+
`managed route ${managedRouteId}`
|
|
1028
|
+
) && cleaned;
|
|
1029
|
+
}
|
|
1030
|
+
return cleaned;
|
|
512
1031
|
}
|
|
513
1032
|
function isPreviewServer(server) {
|
|
514
1033
|
return server.config.isProduction;
|
|
@@ -541,13 +1060,16 @@ function viteCaddyTlsPlugin({
|
|
|
541
1060
|
instanceLabel
|
|
542
1061
|
});
|
|
543
1062
|
const domainArray = resolvedDomains ?? [];
|
|
544
|
-
const
|
|
545
|
-
const
|
|
546
|
-
const
|
|
1063
|
+
const ownerId = createOwnerId();
|
|
1064
|
+
const ownershipRecord = createRouteOwnershipRecord(ownerId, domainArray, config.root);
|
|
1065
|
+
const routeId = ownershipRecord.routeId;
|
|
1066
|
+
const tlsPolicyId = ownershipRecord.tlsPolicyId;
|
|
547
1067
|
let cleanupStarted = false;
|
|
548
1068
|
let resolvedPort = null;
|
|
549
1069
|
let resolvedHost = null;
|
|
550
1070
|
let setupStarted = false;
|
|
1071
|
+
let activeOwnershipRecord = null;
|
|
1072
|
+
let ownershipHeartbeat = null;
|
|
551
1073
|
function buildDomainResolutionMessage() {
|
|
552
1074
|
const issues = [];
|
|
553
1075
|
if (domain !== void 0 && !normalizeDomains(domain)) {
|
|
@@ -653,13 +1175,16 @@ function viteCaddyTlsPlugin({
|
|
|
653
1175
|
async function cleanupRoute() {
|
|
654
1176
|
if (cleanupStarted) return;
|
|
655
1177
|
cleanupStarted = true;
|
|
656
|
-
if (
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
1178
|
+
if (ownershipHeartbeat) {
|
|
1179
|
+
clearInterval(ownershipHeartbeat);
|
|
1180
|
+
ownershipHeartbeat = null;
|
|
1181
|
+
}
|
|
1182
|
+
if (!activeOwnershipRecord) return;
|
|
1183
|
+
const cleaned = await cleanupClaimedResources(activeOwnershipRecord, removeWithRetry);
|
|
1184
|
+
if (cleaned) {
|
|
1185
|
+
await releaseOwnershipRecord(activeOwnershipRecord);
|
|
1186
|
+
activeOwnershipRecord = null;
|
|
661
1187
|
}
|
|
662
|
-
await removeWithRetry(() => removeRoute(routeId, pluginCaddyApiUrl), "route");
|
|
663
1188
|
}
|
|
664
1189
|
function onServerClose() {
|
|
665
1190
|
void cleanupRoute();
|
|
@@ -699,12 +1224,31 @@ function viteCaddyTlsPlugin({
|
|
|
699
1224
|
console.error(`Failed to remove ${label} after ${maxAttempts} attempts.`);
|
|
700
1225
|
return false;
|
|
701
1226
|
}
|
|
1227
|
+
function startOwnershipHeartbeat(record) {
|
|
1228
|
+
ownershipHeartbeat = setInterval(() => {
|
|
1229
|
+
void touchRouteOwnership(record).then((touched) => {
|
|
1230
|
+
if (touched || !ownershipHeartbeat) {
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
console.error(
|
|
1234
|
+
`Lost route ownership for ${domainArray.join(", ")}. Cleaning up managed Caddy resources.`
|
|
1235
|
+
);
|
|
1236
|
+
void cleanupRoute();
|
|
1237
|
+
}).catch((error) => {
|
|
1238
|
+
console.error(
|
|
1239
|
+
`Failed to refresh route ownership for ${domainArray.join(", ")}.`,
|
|
1240
|
+
error
|
|
1241
|
+
);
|
|
1242
|
+
});
|
|
1243
|
+
}, ROUTE_OWNERSHIP_HEARTBEAT_INTERVAL_MS);
|
|
1244
|
+
ownershipHeartbeat.unref?.();
|
|
1245
|
+
}
|
|
702
1246
|
async function setupRoute() {
|
|
703
1247
|
if (!validateCaddyIsInstalled()) {
|
|
704
1248
|
return;
|
|
705
1249
|
}
|
|
706
1250
|
try {
|
|
707
|
-
await ensureCaddyReady(serverName, pluginCaddyApiUrl);
|
|
1251
|
+
await ensureCaddyReady(serverName, pluginCaddyApiUrl, pluginCaddyAdminOrigin);
|
|
708
1252
|
} catch (e) {
|
|
709
1253
|
console.error(
|
|
710
1254
|
`Failed to configure Caddy base settings. Is the Caddy Admin API reachable at ${pluginCaddyApiUrl}?`,
|
|
@@ -714,23 +1258,85 @@ function viteCaddyTlsPlugin({
|
|
|
714
1258
|
}
|
|
715
1259
|
const port = getServerPort();
|
|
716
1260
|
const upstreamHost = getUpstreamHost();
|
|
717
|
-
|
|
1261
|
+
let claimResult;
|
|
1262
|
+
try {
|
|
1263
|
+
claimResult = await claimRouteOwnership(ownershipRecord);
|
|
1264
|
+
} catch (e) {
|
|
1265
|
+
console.error("Failed to claim route ownership for the resolved domains.", e);
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (claimResult.status === "active-conflict") {
|
|
1269
|
+
console.error(
|
|
1270
|
+
buildOwnershipConflictMessage(domainArray, claimResult.existingRecord)
|
|
1271
|
+
);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
activeOwnershipRecord = claimResult.currentRecord;
|
|
1275
|
+
if (claimResult.status === "reclaimed") {
|
|
1276
|
+
let reclaimed = true;
|
|
1277
|
+
for (const previousRecord of claimResult.previousRecords) {
|
|
1278
|
+
reclaimed = await cleanupClaimedResources(previousRecord, removeWithRetry) && reclaimed;
|
|
1279
|
+
}
|
|
1280
|
+
if (!reclaimed) {
|
|
1281
|
+
console.error(
|
|
1282
|
+
`Failed to reclaim stale ownership for ${domainArray.join(", ")}. Try stopping the other server or removing stale Caddy state manually.`
|
|
1283
|
+
);
|
|
1284
|
+
await releaseOwnershipRecord(activeOwnershipRecord);
|
|
1285
|
+
activeOwnershipRecord = null;
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
await releaseOwnershipRecords(claimResult.previousRecords);
|
|
1289
|
+
}
|
|
1290
|
+
const conflictingRouteIds = (await findManagedRoutesForDomains(
|
|
718
1291
|
domainArray,
|
|
719
|
-
routeId,
|
|
720
1292
|
serverName,
|
|
721
|
-
pluginCaddyApiUrl
|
|
722
|
-
|
|
723
|
-
|
|
1293
|
+
pluginCaddyApiUrl,
|
|
1294
|
+
pluginCaddyAdminOrigin
|
|
1295
|
+
)).filter((existingRouteId) => {
|
|
1296
|
+
return existingRouteId !== routeId;
|
|
1297
|
+
});
|
|
1298
|
+
const conflictingTlsPolicyIds = (await findManagedTlsPoliciesForDomains(
|
|
1299
|
+
domainArray,
|
|
1300
|
+
pluginCaddyApiUrl,
|
|
1301
|
+
pluginCaddyAdminOrigin
|
|
1302
|
+
)).filter((existingTlsPolicyId) => {
|
|
1303
|
+
return existingTlsPolicyId !== tlsPolicyId;
|
|
1304
|
+
});
|
|
1305
|
+
if (conflictingRouteIds.length > 0 || conflictingTlsPolicyIds.length > 0) {
|
|
1306
|
+
const reclaimedOrphans = await cleanupManagedResources(
|
|
1307
|
+
conflictingRouteIds,
|
|
1308
|
+
conflictingTlsPolicyIds,
|
|
1309
|
+
removeWithRetry
|
|
1310
|
+
);
|
|
1311
|
+
if (reclaimedOrphans) {
|
|
1312
|
+
console.warn(
|
|
1313
|
+
`Reclaimed orphaned managed Caddy resources for ${domainArray.join(", ")}.`
|
|
1314
|
+
);
|
|
1315
|
+
} else {
|
|
1316
|
+
console.error(
|
|
1317
|
+
`Cannot claim ${domainArray.join(", ")} because Caddy still has orphaned managed resources. Remove the stale Caddy state or use \`instanceLabel\` or \`domain\` to make the hostname unique.`
|
|
1318
|
+
);
|
|
1319
|
+
await releaseOwnershipRecord(activeOwnershipRecord);
|
|
1320
|
+
activeOwnershipRecord = null;
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
724
1324
|
if (tlsPolicyId) {
|
|
725
|
-
await removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl);
|
|
726
1325
|
try {
|
|
727
|
-
await addTlsPolicy(
|
|
1326
|
+
await addTlsPolicy(
|
|
1327
|
+
tlsPolicyId,
|
|
1328
|
+
domainArray,
|
|
1329
|
+
pluginCaddyApiUrl,
|
|
1330
|
+
pluginCaddyAdminOrigin
|
|
1331
|
+
);
|
|
728
1332
|
tlsPolicyAdded = true;
|
|
729
1333
|
} catch (e) {
|
|
730
1334
|
console.error(
|
|
731
1335
|
`Failed to add TLS policy to Caddy. Is the Caddy Admin API reachable at ${pluginCaddyApiUrl}?`,
|
|
732
1336
|
e
|
|
733
1337
|
);
|
|
1338
|
+
await releaseOwnershipRecord(activeOwnershipRecord);
|
|
1339
|
+
activeOwnershipRecord = null;
|
|
734
1340
|
return;
|
|
735
1341
|
}
|
|
736
1342
|
}
|
|
@@ -743,18 +1349,24 @@ function viteCaddyTlsPlugin({
|
|
|
743
1349
|
serverName,
|
|
744
1350
|
upstreamHost,
|
|
745
1351
|
upstreamHostHeader,
|
|
746
|
-
pluginCaddyApiUrl
|
|
1352
|
+
pluginCaddyApiUrl,
|
|
1353
|
+
pluginCaddyAdminOrigin
|
|
747
1354
|
);
|
|
748
1355
|
} catch (e) {
|
|
749
1356
|
if (tlsPolicyAdded && tlsPolicyId) {
|
|
750
|
-
await removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl);
|
|
1357
|
+
await removeTlsPolicy(tlsPolicyId, pluginCaddyApiUrl, pluginCaddyAdminOrigin);
|
|
751
1358
|
}
|
|
1359
|
+
await releaseOwnershipRecord(activeOwnershipRecord);
|
|
1360
|
+
activeOwnershipRecord = null;
|
|
752
1361
|
console.error(
|
|
753
1362
|
`Failed to add route to Caddy. Is the Caddy Admin API reachable at ${pluginCaddyApiUrl}?`,
|
|
754
1363
|
e
|
|
755
1364
|
);
|
|
756
1365
|
return;
|
|
757
1366
|
}
|
|
1367
|
+
if (activeOwnershipRecord) {
|
|
1368
|
+
startOwnershipHeartbeat(activeOwnershipRecord);
|
|
1369
|
+
}
|
|
758
1370
|
console.log("\n\u{1F512} Caddy is proxying your traffic on https");
|
|
759
1371
|
console.log(`
|
|
760
1372
|
\u27A1\uFE0F Upstream target: http://${formatUpstreamTarget(upstreamHost, port)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vite-plugin-caddy-multiple-tls",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Vite plugin that uses Caddy to provide local HTTPS with derived domains.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vite",
|
|
@@ -47,14 +47,14 @@
|
|
|
47
47
|
},
|
|
48
48
|
"homepage": "https://github.com/vampaz/vite-plugin-caddy-multiple-tls/#readme",
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
|
50
|
+
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/fs-extra": "^11.0.4",
|
|
54
54
|
"@types/node": "^25.0.3",
|
|
55
55
|
"fs-extra": "^11.3.3",
|
|
56
56
|
"tsup": "^8.5.1",
|
|
57
|
-
"vite": "^
|
|
57
|
+
"vite": "^8.0.0",
|
|
58
58
|
"vitest": "^4.0.16"
|
|
59
59
|
}
|
|
60
60
|
}
|