@kadi.build/file-sharing 1.0.0 → 1.1.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 +116 -3
- package/package.json +3 -2
- package/src/HttpServerProvider.js +265 -4
- package/src/S3Server.js +233 -18
package/README.md
CHANGED
|
@@ -7,8 +7,10 @@ Integrates [`@kadi.build/file-manager`](https://www.npmjs.com/package/@kadi.buil
|
|
|
7
7
|
|
|
8
8
|
- **HTTP File Server** — Static file serving with directory listing, range requests, CORS, and multi-scheme authentication
|
|
9
9
|
- **Local S3-Compatible API** — Emulates AWS S3 endpoints locally so you can use `@aws-sdk/client-s3` against your local filesystem
|
|
10
|
+
- **Extensible HTTP Pipeline** — Register custom middleware and routes (e.g. Docker Registry v2 endpoints) alongside built-in file serving
|
|
10
11
|
- **Tunnel Integration** — Expose your local server publicly via KĀDI (default), ngrok, serveo, localtunnel, or pinggy
|
|
11
|
-
- **Authentication** — Basic Auth, Bearer Token, and API Key (header / query param) for HTTP; AWS SigV4 / SigV2 / Bearer for S3
|
|
12
|
+
- **Authentication** — Basic Auth, Bearer Token, and API Key (header / query param) for HTTP; AWS SigV4 / SigV2 / Bearer / Basic for S3
|
|
13
|
+
- **Temporary Credentials** — Generate time-limited S3 credentials for secure sharing
|
|
12
14
|
- **Secrets Management** — Automatic `.env` loading (walks up parent directories for monorepo support) + environment variables + explicit config
|
|
13
15
|
- **Download Monitoring** — Track active downloads, progress, speed, and completion statistics
|
|
14
16
|
- **Graceful Shutdown** — Priority-ordered shutdown with timeout and force-kill
|
|
@@ -195,6 +197,7 @@ When custom S3 credentials are set (anything other than the default `minioadmin`
|
|
|
195
197
|
| **AWS SigV4** | Standard `Authorization: AWS4-HMAC-SHA256 Credential=<accessKeyId>/...` |
|
|
196
198
|
| **AWS SigV2** | Legacy `Authorization: AWS <accessKeyId>:signature` |
|
|
197
199
|
| **Bearer** | Convenience `Authorization: Bearer <accessKeyId>` |
|
|
200
|
+
| **Basic** | Docker-style `Authorization: Basic base64(accessKeyId:secretAccessKey)` |
|
|
198
201
|
| **Pre-signed URL** | `?X-Amz-Credential=<accessKeyId>/...` query param |
|
|
199
202
|
|
|
200
203
|
> With default credentials (`minioadmin`/`minioadmin`), auth is skipped for backward compatibility unless you set `enforceAuth: true` in the S3 config.
|
|
@@ -309,6 +312,12 @@ const server = new FileSharingServer({
|
|
|
309
312
|
| `s3:get` | `{bucket, key}` | S3 GetObject |
|
|
310
313
|
| `s3:put` | `{bucket, key}` | S3 PutObject |
|
|
311
314
|
| `s3:delete` | `{bucket, key}` | S3 DeleteObject |
|
|
315
|
+
| `bucket:removed` | `{bucket}` | Bucket programmatically removed |
|
|
316
|
+
| `credentials:generated` | `{accessKey, expiresAt}` | Temporary credentials created |
|
|
317
|
+
| `middleware:added` | `{name, priority}` | HTTP middleware registered |
|
|
318
|
+
| `middleware:removed` | `{name}` | HTTP middleware removed |
|
|
319
|
+
| `route:added` | `{method, path, priority}` | Custom HTTP route registered |
|
|
320
|
+
| `route:removed` | `{method, path}` | Custom HTTP route removed |
|
|
312
321
|
| `tunnel:created` | `TunnelInfo` | Tunnel established |
|
|
313
322
|
| `tunnel:closed` | — | Tunnel shut down |
|
|
314
323
|
| `tunnel:error` | `Error` | Tunnel failed (non-fatal) |
|
|
@@ -317,7 +326,7 @@ const server = new FileSharingServer({
|
|
|
317
326
|
|
|
318
327
|
### HttpServerProvider
|
|
319
328
|
|
|
320
|
-
Low-level HTTP file server with authentication.
|
|
329
|
+
Low-level HTTP file server with authentication and extensibility.
|
|
321
330
|
|
|
322
331
|
```javascript
|
|
323
332
|
import { HttpServerProvider } from '@kadi.build/file-sharing/http';
|
|
@@ -333,6 +342,67 @@ const httpServer = new HttpServerProvider({
|
|
|
333
342
|
await httpServer.start();
|
|
334
343
|
```
|
|
335
344
|
|
|
345
|
+
#### Middleware (Extensibility)
|
|
346
|
+
|
|
347
|
+
Register middleware that runs **before** built-in auth and static file handling. Higher priority runs first.
|
|
348
|
+
|
|
349
|
+
```javascript
|
|
350
|
+
// Add authentication middleware (priority 10 = runs before default handlers)
|
|
351
|
+
httpServer.addMiddleware('dockerAuth', (req, res, next) => {
|
|
352
|
+
if (req.url.startsWith('/v2')) {
|
|
353
|
+
const auth = req.headers.authorization;
|
|
354
|
+
if (!auth || !isValidToken(auth)) {
|
|
355
|
+
res.writeHead(401);
|
|
356
|
+
res.end(JSON.stringify({ errors: [{ code: 'UNAUTHORIZED' }] }));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
req.user = { token: auth };
|
|
360
|
+
}
|
|
361
|
+
next();
|
|
362
|
+
}, { priority: 10 });
|
|
363
|
+
|
|
364
|
+
// List registered middleware
|
|
365
|
+
httpServer.getMiddleware(); // [{ name: 'dockerAuth', priority: 10 }]
|
|
366
|
+
|
|
367
|
+
// Remove middleware
|
|
368
|
+
httpServer.removeMiddleware('dockerAuth');
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
#### Custom Routes (Extensibility)
|
|
372
|
+
|
|
373
|
+
Register custom route handlers with Express-style `:param` path patterns. Custom routes match **before** built-in static file serving.
|
|
374
|
+
|
|
375
|
+
```javascript
|
|
376
|
+
// Docker Registry v2 ping
|
|
377
|
+
httpServer.addCustomRoute('GET', '/v2/', (req, res) => {
|
|
378
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
379
|
+
res.end('{}');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Route with path parameters — req.params is populated automatically
|
|
383
|
+
httpServer.addCustomRoute('GET', '/v2/:name/manifests/:reference', (req, res) => {
|
|
384
|
+
console.log(req.params); // { name: 'myapp', reference: 'latest' }
|
|
385
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
386
|
+
res.end(JSON.stringify(getManifest(req.params.name, req.params.reference)));
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// HEAD method support
|
|
390
|
+
httpServer.addCustomRoute('HEAD', '/v2/:name/blobs/:digest', (req, res) => {
|
|
391
|
+
res.writeHead(200, { 'Docker-Content-Digest': req.params.digest });
|
|
392
|
+
res.end();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// List / remove routes
|
|
396
|
+
httpServer.getCustomRoutes(); // [{ method, path, priority }]
|
|
397
|
+
httpServer.removeCustomRoute('GET', '/v2/'); // true if found
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
#### Request Pipeline Order
|
|
401
|
+
|
|
402
|
+
```
|
|
403
|
+
Request → CORS → Middleware Chain → Custom Routes → Built-in Auth → Static Files
|
|
404
|
+
```
|
|
405
|
+
|
|
336
406
|
### S3Server
|
|
337
407
|
|
|
338
408
|
Local S3-compatible API server. Files are stored **on disk**, not on AWS.
|
|
@@ -350,7 +420,8 @@ const s3 = new S3Server({
|
|
|
350
420
|
enforceAuth: true // validate even with default creds
|
|
351
421
|
});
|
|
352
422
|
|
|
353
|
-
await s3.start();
|
|
423
|
+
const info = await s3.start();
|
|
424
|
+
console.log(info.serverId); // 'kadi-s3-9000'
|
|
354
425
|
```
|
|
355
426
|
|
|
356
427
|
**Supported S3 Operations:**
|
|
@@ -363,6 +434,48 @@ await s3.start();
|
|
|
363
434
|
- `HeadObject`
|
|
364
435
|
- `CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload`
|
|
365
436
|
|
|
437
|
+
#### Programmatic Authentication Validation
|
|
438
|
+
|
|
439
|
+
Validate incoming requests against configured credentials. Returns a structured result — useful for layering custom auth (e.g. Docker Registry) on top of S3.
|
|
440
|
+
|
|
441
|
+
```javascript
|
|
442
|
+
const result = s3.validateAuthentication(req);
|
|
443
|
+
// Success: { success: true, user: { accessKey: 'AKIA...' } }
|
|
444
|
+
// Failure: { success: false, error: 'Invalid credentials' }
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Supports AWS SigV4, SigV2, Bearer, Basic Auth (Docker clients), and pre-signed URL query params.
|
|
448
|
+
|
|
449
|
+
#### Temporary Credentials
|
|
450
|
+
|
|
451
|
+
Generate time-limited credentials for secure sharing:
|
|
452
|
+
|
|
453
|
+
```javascript
|
|
454
|
+
const creds = s3.generateTemporaryCredentials({ ttl: 3600 }); // 1 hour
|
|
455
|
+
console.log(creds.accessKey); // 'AKIA...' (random)
|
|
456
|
+
console.log(creds.secretKey); // random hex string
|
|
457
|
+
console.log(creds.expiresAt); // '2026-02-16T...' (ISO string)
|
|
458
|
+
console.log(creds.expiry); // Date object (backward compat)
|
|
459
|
+
|
|
460
|
+
// Temp credentials are automatically validated by _checkS3Auth and validateAuthentication
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
#### Programmatic Bucket Removal
|
|
464
|
+
|
|
465
|
+
Force-delete a bucket directory and all its contents (for cleanup):
|
|
466
|
+
|
|
467
|
+
```javascript
|
|
468
|
+
await s3.removeBucket('container-id-123');
|
|
469
|
+
// Emits 'bucket:removed' event. Idempotent (no error if bucket doesn't exist).
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
#### Properties
|
|
473
|
+
|
|
474
|
+
| Property | Type | Description |
|
|
475
|
+
|----------|------|-------------|
|
|
476
|
+
| `serverId` | `string` | Stable server identifier (e.g. `kadi-s3-9000`) |
|
|
477
|
+
| `isRunning` | `boolean` | Whether the server is running |
|
|
478
|
+
|
|
366
479
|
### createQuickShare
|
|
367
480
|
|
|
368
481
|
One-liner to share a directory with optional tunnel and auth.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kadi.build/file-sharing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "File sharing service with tunneling and local S3-compatible interface",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"test:integration": "node tests/test-file-sharing.js",
|
|
30
30
|
"test:monitor": "node tests/test-download-monitor.js",
|
|
31
31
|
"test:shutdown": "node tests/test-shutdown-manager.js",
|
|
32
|
-
"test:streaming": "node tests/test-streaming.js"
|
|
32
|
+
"test:streaming": "node tests/test-streaming.js",
|
|
33
|
+
"test:extensibility": "node tests/test-extensibility.js"
|
|
33
34
|
},
|
|
34
35
|
"keywords": [
|
|
35
36
|
"file-sharing",
|
|
@@ -45,6 +45,10 @@ export class HttpServerProvider extends EventEmitter {
|
|
|
45
45
|
this.startTime = null;
|
|
46
46
|
this.requestCount = 0;
|
|
47
47
|
this.errorCount = 0;
|
|
48
|
+
|
|
49
|
+
// Extensibility: middleware chain and custom routes (sorted by priority desc)
|
|
50
|
+
this._middlewares = [];
|
|
51
|
+
this._customRoutes = [];
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/**
|
|
@@ -143,6 +147,243 @@ export class HttpServerProvider extends EventEmitter {
|
|
|
143
147
|
}
|
|
144
148
|
}
|
|
145
149
|
|
|
150
|
+
// ============================================================================
|
|
151
|
+
// EXTENSIBILITY — MIDDLEWARE & CUSTOM ROUTES
|
|
152
|
+
// ============================================================================
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Register a named middleware function into the request pipeline.
|
|
156
|
+
* Middleware runs BEFORE built-in auth and static-file handling.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} name - Unique middleware identifier (e.g. 'dockerAuth')
|
|
159
|
+
* @param {Function} handler - (req, res, next) => void
|
|
160
|
+
* @param {Object} [opts]
|
|
161
|
+
* @param {number} [opts.priority=0] - Higher runs first
|
|
162
|
+
*/
|
|
163
|
+
addMiddleware(name, handler, opts = {}) {
|
|
164
|
+
if (typeof name !== 'string' || !name) {
|
|
165
|
+
throw new Error('Middleware name must be a non-empty string');
|
|
166
|
+
}
|
|
167
|
+
if (typeof handler !== 'function') {
|
|
168
|
+
throw new Error('Middleware handler must be a function');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Remove existing middleware with the same name (upsert semantics)
|
|
172
|
+
this._middlewares = this._middlewares.filter(m => m.name !== name);
|
|
173
|
+
|
|
174
|
+
const priority = opts.priority ?? 0;
|
|
175
|
+
this._middlewares.push({ name, handler, priority });
|
|
176
|
+
|
|
177
|
+
// Sort descending by priority (higher priority runs first)
|
|
178
|
+
this._middlewares.sort((a, b) => b.priority - a.priority);
|
|
179
|
+
|
|
180
|
+
this.emit('middleware:added', { name, priority });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Remove a named middleware.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} name - Middleware identifier to remove
|
|
187
|
+
* @returns {boolean} true if middleware was found and removed
|
|
188
|
+
*/
|
|
189
|
+
removeMiddleware(name) {
|
|
190
|
+
const before = this._middlewares.length;
|
|
191
|
+
this._middlewares = this._middlewares.filter(m => m.name !== name);
|
|
192
|
+
const removed = this._middlewares.length < before;
|
|
193
|
+
if (removed) {
|
|
194
|
+
this.emit('middleware:removed', { name });
|
|
195
|
+
}
|
|
196
|
+
return removed;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get the list of registered middleware names (in execution order).
|
|
201
|
+
* @returns {string[]}
|
|
202
|
+
*/
|
|
203
|
+
getMiddleware() {
|
|
204
|
+
return this._middlewares.map(m => ({ name: m.name, priority: m.priority }));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Register a custom route handler.
|
|
209
|
+
* Custom routes are matched BEFORE built-in static-file/upload handling.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} method - HTTP method ('GET', 'HEAD', 'POST', etc.)
|
|
212
|
+
* @param {string} urlPath - URL path or pattern. Supports Express-style :param segments.
|
|
213
|
+
* @param {Function} handler - (req, res) => void. req.params populated from path pattern.
|
|
214
|
+
* @param {Object} [opts]
|
|
215
|
+
* @param {number} [opts.priority=0] - Higher = matched first (before S3/static routes)
|
|
216
|
+
*/
|
|
217
|
+
addCustomRoute(method, urlPath, handler, opts = {}) {
|
|
218
|
+
if (typeof method !== 'string' || !method) {
|
|
219
|
+
throw new Error('Route method must be a non-empty string');
|
|
220
|
+
}
|
|
221
|
+
if (typeof urlPath !== 'string') {
|
|
222
|
+
throw new Error('Route path must be a string');
|
|
223
|
+
}
|
|
224
|
+
if (typeof handler !== 'function') {
|
|
225
|
+
throw new Error('Route handler must be a function');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const normalizedMethod = method.toUpperCase();
|
|
229
|
+
const priority = opts.priority ?? 0;
|
|
230
|
+
const compiled = this._compilePath(urlPath);
|
|
231
|
+
|
|
232
|
+
// Remove existing route with same method + path (upsert)
|
|
233
|
+
this._customRoutes = this._customRoutes.filter(
|
|
234
|
+
r => !(r.method === normalizedMethod && r.path === urlPath)
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
this._customRoutes.push({
|
|
238
|
+
method: normalizedMethod,
|
|
239
|
+
path: urlPath,
|
|
240
|
+
handler,
|
|
241
|
+
priority,
|
|
242
|
+
...compiled
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Sort descending by priority
|
|
246
|
+
this._customRoutes.sort((a, b) => b.priority - a.priority);
|
|
247
|
+
|
|
248
|
+
this.emit('route:added', { method: normalizedMethod, path: urlPath, priority });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Remove a custom route.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} method - HTTP method
|
|
255
|
+
* @param {string} urlPath - URL path pattern
|
|
256
|
+
* @returns {boolean} true if route was found and removed
|
|
257
|
+
*/
|
|
258
|
+
removeCustomRoute(method, urlPath) {
|
|
259
|
+
const normalizedMethod = method.toUpperCase();
|
|
260
|
+
const before = this._customRoutes.length;
|
|
261
|
+
this._customRoutes = this._customRoutes.filter(
|
|
262
|
+
r => !(r.method === normalizedMethod && r.path === urlPath)
|
|
263
|
+
);
|
|
264
|
+
const removed = this._customRoutes.length < before;
|
|
265
|
+
if (removed) {
|
|
266
|
+
this.emit('route:removed', { method: normalizedMethod, path: urlPath });
|
|
267
|
+
}
|
|
268
|
+
return removed;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Get the list of registered custom routes.
|
|
273
|
+
* @returns {Array<{ method: string, path: string, priority: number }>}
|
|
274
|
+
*/
|
|
275
|
+
getCustomRoutes() {
|
|
276
|
+
return this._customRoutes.map(r => ({ method: r.method, path: r.path, priority: r.priority }));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Compile an Express-style path pattern into a regex and param name list.
|
|
281
|
+
*
|
|
282
|
+
* Supports:
|
|
283
|
+
* - Literal paths: `/v2/`
|
|
284
|
+
* - Named params: `/v2/:name/manifests/:reference`
|
|
285
|
+
*
|
|
286
|
+
* @private
|
|
287
|
+
* @param {string} pattern - URL path pattern
|
|
288
|
+
* @returns {{ regex: RegExp, paramNames: string[] }}
|
|
289
|
+
*/
|
|
290
|
+
_compilePath(pattern) {
|
|
291
|
+
const paramNames = [];
|
|
292
|
+
// Escape special regex chars except : (used for params) and /
|
|
293
|
+
const regexStr = pattern
|
|
294
|
+
.replace(/([.+?^${}()|[\]\\])/g, '\\$1')
|
|
295
|
+
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
|
296
|
+
paramNames.push(paramName);
|
|
297
|
+
return '([^/]+)';
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Exact match — anchor both ends
|
|
301
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
302
|
+
return { regex, paramNames };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Run the middleware chain. Returns true if a middleware ended the response.
|
|
307
|
+
* @private
|
|
308
|
+
* @param {import('http').IncomingMessage} req
|
|
309
|
+
* @param {import('http').ServerResponse} res
|
|
310
|
+
* @returns {Promise<boolean>} true if response was ended by middleware
|
|
311
|
+
*/
|
|
312
|
+
async _runMiddlewareChain(req, res) {
|
|
313
|
+
const middlewares = this._middlewares;
|
|
314
|
+
if (middlewares.length === 0) return false;
|
|
315
|
+
|
|
316
|
+
let index = 0;
|
|
317
|
+
let responseEnded = false;
|
|
318
|
+
|
|
319
|
+
const runNext = () => {
|
|
320
|
+
return new Promise((resolve, reject) => {
|
|
321
|
+
if (responseEnded || res.writableEnded) {
|
|
322
|
+
responseEnded = true;
|
|
323
|
+
return resolve();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (index >= middlewares.length) {
|
|
327
|
+
return resolve(); // All middleware passed through
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const mw = middlewares[index++];
|
|
331
|
+
try {
|
|
332
|
+
const result = mw.handler(req, res, (err) => {
|
|
333
|
+
if (err) {
|
|
334
|
+
return reject(err);
|
|
335
|
+
}
|
|
336
|
+
if (res.writableEnded) {
|
|
337
|
+
responseEnded = true;
|
|
338
|
+
return resolve();
|
|
339
|
+
}
|
|
340
|
+
runNext().then(resolve).catch(reject);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Support async middleware that returns a promise
|
|
344
|
+
if (result && typeof result.then === 'function') {
|
|
345
|
+
result.catch(reject);
|
|
346
|
+
}
|
|
347
|
+
} catch (err) {
|
|
348
|
+
reject(err);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
await runNext();
|
|
354
|
+
return responseEnded || res.writableEnded;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Try to match an incoming request against registered custom routes.
|
|
359
|
+
* @private
|
|
360
|
+
* @param {string} method - HTTP method
|
|
361
|
+
* @param {string} pathname - Decoded URL pathname
|
|
362
|
+
* @returns {{ route: object, params: object } | null}
|
|
363
|
+
*/
|
|
364
|
+
_matchCustomRoute(method, pathname) {
|
|
365
|
+
const upperMethod = method.toUpperCase();
|
|
366
|
+
|
|
367
|
+
for (const route of this._customRoutes) {
|
|
368
|
+
if (route.method !== upperMethod) continue;
|
|
369
|
+
|
|
370
|
+
const match = route.regex.exec(pathname);
|
|
371
|
+
if (match) {
|
|
372
|
+
const params = {};
|
|
373
|
+
route.paramNames.forEach((name, i) => {
|
|
374
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
375
|
+
});
|
|
376
|
+
return { route, params };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ============================================================================
|
|
384
|
+
// REQUEST HANDLING
|
|
385
|
+
// ============================================================================
|
|
386
|
+
|
|
146
387
|
/**
|
|
147
388
|
* Create the main request handler
|
|
148
389
|
* @private
|
|
@@ -163,7 +404,29 @@ export class HttpServerProvider extends EventEmitter {
|
|
|
163
404
|
}
|
|
164
405
|
}
|
|
165
406
|
|
|
166
|
-
//
|
|
407
|
+
// ── Middleware chain (Gap 1) ──────────────────────────────────
|
|
408
|
+
// Run registered middleware before auth and routing.
|
|
409
|
+
// If any middleware ends the response, stop processing.
|
|
410
|
+
if (this._middlewares.length > 0) {
|
|
411
|
+
const handled = await this._runMiddlewareChain(req, res);
|
|
412
|
+
if (handled) return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Custom routes (Gap 2) ────────────────────────────────────
|
|
416
|
+
// Check registered custom routes before built-in handling.
|
|
417
|
+
const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
418
|
+
const urlPath = decodeURIComponent(urlObj.pathname);
|
|
419
|
+
|
|
420
|
+
if (this._customRoutes.length > 0) {
|
|
421
|
+
const routeMatch = this._matchCustomRoute(req.method, urlPath);
|
|
422
|
+
if (routeMatch) {
|
|
423
|
+
req.params = routeMatch.params;
|
|
424
|
+
await routeMatch.route.handler(req, res);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Built-in authentication ──────────────────────────────────
|
|
167
430
|
if (this.config.auth) {
|
|
168
431
|
if (!this._checkAuth(req)) {
|
|
169
432
|
// Set appropriate WWW-Authenticate based on configured scheme
|
|
@@ -179,9 +442,7 @@ export class HttpServerProvider extends EventEmitter {
|
|
|
179
442
|
}
|
|
180
443
|
}
|
|
181
444
|
|
|
182
|
-
//
|
|
183
|
-
const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
184
|
-
const urlPath = decodeURIComponent(urlObj.pathname);
|
|
445
|
+
// ── Static file handling ─────────────────────────────────────
|
|
185
446
|
const filePath = path.join(this.config.staticDir, urlPath);
|
|
186
447
|
|
|
187
448
|
// Security: prevent directory traversal
|
package/src/S3Server.js
CHANGED
|
@@ -48,6 +48,20 @@ export class S3Server extends EventEmitter {
|
|
|
48
48
|
this.isRunning = false;
|
|
49
49
|
this.multipartUploads = new Map();
|
|
50
50
|
this.requestCount = 0;
|
|
51
|
+
|
|
52
|
+
// Temporary credentials store (Gap 7) — Map<accessKey, { secretKey, expiresAt }>
|
|
53
|
+
this._temporaryCredentials = new Map();
|
|
54
|
+
|
|
55
|
+
// Stable server identifier (Gap 5)
|
|
56
|
+
this._serverId = `kadi-s3-${this.config.port}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Stable server identifier.
|
|
61
|
+
* @type {string}
|
|
62
|
+
*/
|
|
63
|
+
get serverId() {
|
|
64
|
+
return this._serverId;
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
/**
|
|
@@ -102,10 +116,13 @@ export class S3Server extends EventEmitter {
|
|
|
102
116
|
const addr = this.server.address();
|
|
103
117
|
this.config.port = addr.port;
|
|
104
118
|
this.isRunning = true;
|
|
119
|
+
this._serverId = `kadi-s3-${addr.port}`;
|
|
105
120
|
|
|
106
121
|
const info = {
|
|
107
122
|
port: addr.port,
|
|
108
|
-
|
|
123
|
+
host: this.config.host,
|
|
124
|
+
endpoint: `http://${this.config.host}:${addr.port}`,
|
|
125
|
+
serverId: this._serverId
|
|
109
126
|
};
|
|
110
127
|
|
|
111
128
|
this.emit('started', info);
|
|
@@ -893,10 +910,170 @@ export class S3Server extends EventEmitter {
|
|
|
893
910
|
res.end(xml);
|
|
894
911
|
}
|
|
895
912
|
|
|
913
|
+
// ============================================================================
|
|
914
|
+
// PUBLIC EXTENSIBILITY APIs
|
|
915
|
+
// ============================================================================
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Validate an incoming request against the server's configured credentials.
|
|
919
|
+
* Returns a structured result object (unlike the private _checkS3Auth which
|
|
920
|
+
* returns a plain boolean).
|
|
921
|
+
*
|
|
922
|
+
* This is the public API that consumers like container-registry-ability use
|
|
923
|
+
* to validate Docker Registry API requests carry valid S3 credentials.
|
|
924
|
+
*
|
|
925
|
+
* @param {import('http').IncomingMessage} req
|
|
926
|
+
* @returns {{ success: boolean, user?: { accessKey: string }, error?: string }}
|
|
927
|
+
*/
|
|
928
|
+
validateAuthentication(req) {
|
|
929
|
+
// Attempt to extract the access key from the request for the user object
|
|
930
|
+
const accessKey = this._extractAccessKey(req);
|
|
931
|
+
const isValid = this._checkS3Auth(req);
|
|
932
|
+
|
|
933
|
+
if (isValid) {
|
|
934
|
+
return {
|
|
935
|
+
success: true,
|
|
936
|
+
user: { accessKey: accessKey || this.config.accessKeyId }
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return {
|
|
941
|
+
success: false,
|
|
942
|
+
error: 'Invalid credentials'
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Programmatically remove a bucket (delete its directory from the S3 root).
|
|
948
|
+
* Unlike the S3 API DeleteBucket handler, this force-deletes even non-empty
|
|
949
|
+
* directories — intended for cleanup when containers are removed.
|
|
950
|
+
*
|
|
951
|
+
* @param {string} bucketName - The bucket/directory name to remove
|
|
952
|
+
* @returns {Promise<void>}
|
|
953
|
+
*/
|
|
954
|
+
async removeBucket(bucketName) {
|
|
955
|
+
if (!bucketName || typeof bucketName !== 'string') {
|
|
956
|
+
throw new Error('bucketName must be a non-empty string');
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Security: prevent directory traversal
|
|
960
|
+
if (bucketName.includes('..') || bucketName.includes('/') || bucketName.includes('\\')) {
|
|
961
|
+
throw new Error('Invalid bucket name — must not contain path separators or ".."');
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const bucketDir = path.join(this.config.rootDir, bucketName);
|
|
965
|
+
|
|
966
|
+
// Ensure the resolved path is within rootDir
|
|
967
|
+
const resolvedRoot = path.resolve(this.config.rootDir);
|
|
968
|
+
const resolvedBucket = path.resolve(bucketDir);
|
|
969
|
+
if (!resolvedBucket.startsWith(resolvedRoot)) {
|
|
970
|
+
throw new Error('Invalid bucket name — path traversal detected');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
try {
|
|
974
|
+
await fs.rm(bucketDir, { recursive: true, force: true });
|
|
975
|
+
this.emit('bucket:removed', { bucket: bucketName });
|
|
976
|
+
} catch (error) {
|
|
977
|
+
if (error.code !== 'ENOENT') {
|
|
978
|
+
throw error;
|
|
979
|
+
}
|
|
980
|
+
// Bucket doesn't exist — treat as success (idempotent)
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Generate temporary S3 credentials with an expiry.
|
|
986
|
+
*
|
|
987
|
+
* Returns both `expiresAt` (ISO string) and `expiry` (Date object) for
|
|
988
|
+
* backward compatibility with consumers expecting the old property name.
|
|
989
|
+
*
|
|
990
|
+
* @param {Object} [opts]
|
|
991
|
+
* @param {number} [opts.ttl=3600] - Time-to-live in seconds (default: 1 hour)
|
|
992
|
+
* @returns {{ accessKey: string, secretKey: string, expiresAt: string, expiry: Date }}
|
|
993
|
+
*/
|
|
994
|
+
generateTemporaryCredentials(opts = {}) {
|
|
995
|
+
const ttl = opts.ttl ?? 3600;
|
|
996
|
+
const accessKey = `AKIA${crypto.randomBytes(8).toString('hex').toUpperCase()}`;
|
|
997
|
+
const secretKey = crypto.randomBytes(20).toString('hex');
|
|
998
|
+
const expiryDate = new Date(Date.now() + ttl * 1000);
|
|
999
|
+
|
|
1000
|
+
// Store for validation
|
|
1001
|
+
this._temporaryCredentials.set(accessKey, {
|
|
1002
|
+
secretKey,
|
|
1003
|
+
expiresAt: expiryDate.getTime()
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
// Schedule cleanup
|
|
1007
|
+
setTimeout(() => {
|
|
1008
|
+
this._temporaryCredentials.delete(accessKey);
|
|
1009
|
+
}, ttl * 1000).unref();
|
|
1010
|
+
|
|
1011
|
+
this.emit('credentials:generated', { accessKey, expiresAt: expiryDate.toISOString() });
|
|
1012
|
+
|
|
1013
|
+
return {
|
|
1014
|
+
accessKey,
|
|
1015
|
+
secretKey,
|
|
1016
|
+
expiresAt: expiryDate.toISOString(),
|
|
1017
|
+
expiry: expiryDate // backward compat alias
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
896
1021
|
// ============================================================================
|
|
897
1022
|
// AUTHENTICATION
|
|
898
1023
|
// ============================================================================
|
|
899
1024
|
|
|
1025
|
+
/**
|
|
1026
|
+
* Extract the access key from an incoming request, if present.
|
|
1027
|
+
* Used by validateAuthentication() to populate the user object.
|
|
1028
|
+
*
|
|
1029
|
+
* @private
|
|
1030
|
+
* @param {import('http').IncomingMessage} req
|
|
1031
|
+
* @returns {string|null}
|
|
1032
|
+
*/
|
|
1033
|
+
_extractAccessKey(req) {
|
|
1034
|
+
const authHeader = req.headers.authorization || '';
|
|
1035
|
+
|
|
1036
|
+
// AWS Signature V4
|
|
1037
|
+
if (authHeader.startsWith('AWS4-HMAC-SHA256')) {
|
|
1038
|
+
const credMatch = authHeader.match(/Credential=([^/,\s]+)/);
|
|
1039
|
+
if (credMatch) return credMatch[1];
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// AWS Signature V2
|
|
1043
|
+
if (authHeader.startsWith('AWS ')) {
|
|
1044
|
+
return authHeader.slice(4).split(':')[0] || null;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Bearer
|
|
1048
|
+
if (authHeader.startsWith('Bearer ')) {
|
|
1049
|
+
return authHeader.slice(7) || null;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Basic auth (Docker clients)
|
|
1053
|
+
if (authHeader.startsWith('Basic ')) {
|
|
1054
|
+
try {
|
|
1055
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
|
|
1056
|
+
const [user] = decoded.split(':');
|
|
1057
|
+
return user || null;
|
|
1058
|
+
} catch {
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Query params
|
|
1064
|
+
try {
|
|
1065
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
1066
|
+
const amzCred = url.searchParams.get('X-Amz-Credential');
|
|
1067
|
+
if (amzCred) return amzCred.split('/')[0];
|
|
1068
|
+
const awsKey = url.searchParams.get('AWSAccessKeyId');
|
|
1069
|
+
if (awsKey) return awsKey;
|
|
1070
|
+
} catch {
|
|
1071
|
+
// ignore
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
900
1077
|
/**
|
|
901
1078
|
* Validate S3 request authentication.
|
|
902
1079
|
*
|
|
@@ -907,17 +1084,13 @@ export class S3Server extends EventEmitter {
|
|
|
907
1084
|
* - AWS Signature V4: `AWS4-HMAC-SHA256 Credential=<accessKeyId>/…`
|
|
908
1085
|
* - AWS Signature V2: `AWS <accessKeyId>:…`
|
|
909
1086
|
* - Simple Bearer: `Bearer <accessKeyId>` (non-standard convenience)
|
|
1087
|
+
* - Basic auth: `Basic base64(accessKey:secretKey)` (Docker clients)
|
|
910
1088
|
*
|
|
911
1089
|
* 2. Query-string pre-signed URL parameters
|
|
912
1090
|
* - V4: `?X-Amz-Credential=<accessKeyId>/…`
|
|
913
1091
|
* - V2: `?AWSAccessKeyId=<accessKeyId>`
|
|
914
1092
|
*
|
|
915
|
-
*
|
|
916
|
-
* than a full HMAC signature check. A full SigV4 implementation would
|
|
917
|
-
* require reconstructing the canonical request exactly as the SDK built it,
|
|
918
|
-
* which is fragile and unnecessary for a local dev server. The goal is to
|
|
919
|
-
* prevent accidental unauthenticated access when credentials are configured,
|
|
920
|
-
* not to replicate AWS IAM.
|
|
1093
|
+
* Also checks temporary credentials generated via generateTemporaryCredentials().
|
|
921
1094
|
*
|
|
922
1095
|
* @private
|
|
923
1096
|
* @param {import('http').IncomingMessage} req
|
|
@@ -939,23 +1112,41 @@ export class S3Server extends EventEmitter {
|
|
|
939
1112
|
// AWS Signature V4: "AWS4-HMAC-SHA256 Credential=<accessKeyId>/date/region/s3/aws4_request, …"
|
|
940
1113
|
if (authHeader.startsWith('AWS4-HMAC-SHA256')) {
|
|
941
1114
|
const credMatch = authHeader.match(/Credential=([^/,\s]+)/);
|
|
942
|
-
if (credMatch
|
|
943
|
-
return true;
|
|
1115
|
+
if (credMatch) {
|
|
1116
|
+
if (credMatch[1] === accessKeyId) return true;
|
|
1117
|
+
// Check temporary credentials
|
|
1118
|
+
if (this._checkTempCredentials(credMatch[1])) return true;
|
|
944
1119
|
}
|
|
945
1120
|
}
|
|
946
1121
|
|
|
947
1122
|
// AWS Signature V2: "AWS <accessKeyId>:<signature>"
|
|
948
1123
|
if (authHeader.startsWith('AWS ')) {
|
|
949
1124
|
const parts = authHeader.slice(4).split(':');
|
|
950
|
-
if (parts[0] === accessKeyId)
|
|
951
|
-
|
|
952
|
-
}
|
|
1125
|
+
if (parts[0] === accessKeyId) return true;
|
|
1126
|
+
if (this._checkTempCredentials(parts[0])) return true;
|
|
953
1127
|
}
|
|
954
1128
|
|
|
955
1129
|
// Simple Bearer (non-standard convenience for agents/scripts)
|
|
956
1130
|
if (authHeader.startsWith('Bearer ')) {
|
|
957
|
-
|
|
958
|
-
|
|
1131
|
+
const token = authHeader.slice(7);
|
|
1132
|
+
if (token === accessKeyId) return true;
|
|
1133
|
+
if (this._checkTempCredentials(token)) return true;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Basic auth (Docker clients send Basic base64(accessKey:secretKey))
|
|
1137
|
+
if (authHeader.startsWith('Basic ')) {
|
|
1138
|
+
try {
|
|
1139
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
|
|
1140
|
+
const [user, pass] = decoded.split(':');
|
|
1141
|
+
// Check against primary credentials
|
|
1142
|
+
if (user === accessKeyId && pass === secretAccessKey) return true;
|
|
1143
|
+
// Check against temporary credentials
|
|
1144
|
+
const tempCred = this._temporaryCredentials.get(user);
|
|
1145
|
+
if (tempCred && tempCred.expiresAt > Date.now() && pass === tempCred.secretKey) {
|
|
1146
|
+
return true;
|
|
1147
|
+
}
|
|
1148
|
+
} catch {
|
|
1149
|
+
// Malformed Basic auth — fall through
|
|
959
1150
|
}
|
|
960
1151
|
}
|
|
961
1152
|
|
|
@@ -965,13 +1156,17 @@ export class S3Server extends EventEmitter {
|
|
|
965
1156
|
|
|
966
1157
|
// V4 pre-signed: ?X-Amz-Credential=<accessKeyId>/…
|
|
967
1158
|
const amzCred = url.searchParams.get('X-Amz-Credential');
|
|
968
|
-
if (amzCred
|
|
969
|
-
|
|
1159
|
+
if (amzCred) {
|
|
1160
|
+
const credKey = amzCred.split('/')[0];
|
|
1161
|
+
if (credKey === accessKeyId) return true;
|
|
1162
|
+
if (this._checkTempCredentials(credKey)) return true;
|
|
970
1163
|
}
|
|
971
1164
|
|
|
972
1165
|
// V2 pre-signed: ?AWSAccessKeyId=<accessKeyId>
|
|
973
|
-
|
|
974
|
-
|
|
1166
|
+
const awsKey = url.searchParams.get('AWSAccessKeyId');
|
|
1167
|
+
if (awsKey) {
|
|
1168
|
+
if (awsKey === accessKeyId) return true;
|
|
1169
|
+
if (this._checkTempCredentials(awsKey)) return true;
|
|
975
1170
|
}
|
|
976
1171
|
} catch {
|
|
977
1172
|
// Malformed URL — deny
|
|
@@ -979,6 +1174,26 @@ export class S3Server extends EventEmitter {
|
|
|
979
1174
|
|
|
980
1175
|
return false;
|
|
981
1176
|
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Check if an access key matches a valid, non-expired temporary credential.
|
|
1180
|
+
*
|
|
1181
|
+
* @private
|
|
1182
|
+
* @param {string} accessKey
|
|
1183
|
+
* @returns {boolean}
|
|
1184
|
+
*/
|
|
1185
|
+
_checkTempCredentials(accessKey) {
|
|
1186
|
+
const cred = this._temporaryCredentials.get(accessKey);
|
|
1187
|
+
if (!cred) return false;
|
|
1188
|
+
|
|
1189
|
+
// Expired?
|
|
1190
|
+
if (cred.expiresAt <= Date.now()) {
|
|
1191
|
+
this._temporaryCredentials.delete(accessKey);
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
return true;
|
|
1196
|
+
}
|
|
982
1197
|
}
|
|
983
1198
|
|
|
984
1199
|
export default S3Server;
|