@dutodev/cli 1.6.1 → 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
CHANGED
|
@@ -154,7 +154,7 @@ People get in one of these ways:
|
|
|
154
154
|
| `curl`, Postman, scripts | `curl -u client:password https://backend-<user>.duto.dev/` (`Authorization: Basic`) |
|
|
155
155
|
| An API that already uses `Authorization: Bearer` | Send `X-Duto-Auth: client:password`; the `Bearer` goes through to the app untouched |
|
|
156
156
|
|
|
157
|
-
Without credentials, a browser is sent to the sign-in page and other clients get `401`. Your local app never receives Duto's credentials: the `Authorization: Basic` that matched, `X-Duto-Auth` and the tunnel cookie are stripped before the request is forwarded (other cookies pass through). An `Authorization: Bearer` from your own app passes unchanged.
|
|
157
|
+
Without credentials, a browser is sent to the sign-in page and other clients get `401`. After 10 wrong passwords from one address in 5 minutes, that address is locked out for the rest of the window (the sign-in page answers `429` with `Retry-After`, other clients keep getting `401`). After 100 wrong passwords from all addresses, every password attempt is locked out the same way. A browser that already signed in keeps working. Your local app never receives Duto's credentials: the `Authorization: Basic` that matched, `X-Duto-Auth` and the tunnel cookie are stripped before the request is forwarded (other cookies pass through). An `Authorization: Bearer` from your own app passes unchanged.
|
|
158
158
|
|
|
159
159
|
**Signing out and sessions.** In a browser, signing in sets a cookie that is valid for that tunnel only. To sign out, open `/__duto/logout`: it clears the cookie and returns to the sign-in page. The cookie lasts until the browser closes or the CLI stops. Restarting the CLI signs everyone out, because the key that signs the cookies is new every session. The cookie only counts on requests to the tunnel itself: a page on another origin (another tunnel, another site) that calls the tunnel has to send `X-Duto-Auth` or `Authorization: Basic`. So opening the tunnel from a link on another `*.duto.dev` address (the dashboard or another tunnel) asks for the password again; a link from outside (email, chat) or a typed URL gets straight in.
|
|
160
160
|
|
package/dist/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
statusColor,
|
|
28
28
|
updateLine,
|
|
29
29
|
validateName
|
|
30
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-QVJHHURM.js";
|
|
31
31
|
|
|
32
32
|
// src/ui/brand.ts
|
|
33
33
|
function colorOn(stream = process.stdout) {
|
|
@@ -57,7 +57,7 @@ import { join as join5, relative } from "path";
|
|
|
57
57
|
import cac from "cac";
|
|
58
58
|
|
|
59
59
|
// src/version.ts
|
|
60
|
-
var CLI_VERSION = "1.
|
|
60
|
+
var CLI_VERSION = "1.7.0";
|
|
61
61
|
|
|
62
62
|
// src/api.ts
|
|
63
63
|
var DutoApiError = class extends Error {
|
|
@@ -561,7 +561,7 @@ async function browserLogin(o) {
|
|
|
561
561
|
const start = await api.startLogin({
|
|
562
562
|
deviceId: o.deviceId,
|
|
563
563
|
deviceName: hostname(),
|
|
564
|
-
cliVersion: "1.
|
|
564
|
+
cliVersion: "1.7.0",
|
|
565
565
|
nodeVersion: process.version,
|
|
566
566
|
os: process.platform,
|
|
567
567
|
arch: process.arch
|
|
@@ -2175,6 +2175,48 @@ async function isPortListening(port, timeoutMs = 500) {
|
|
|
2175
2175
|
|
|
2176
2176
|
// src/session/auth.ts
|
|
2177
2177
|
import { createHash as createHash3, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
2178
|
+
|
|
2179
|
+
// src/session/attempts.ts
|
|
2180
|
+
var ATTEMPT_WINDOW_MS = 5 * 6e4;
|
|
2181
|
+
var MAX_FAILURES_PER_CLIENT = 10;
|
|
2182
|
+
var MAX_FAILURES_TOTAL = 100;
|
|
2183
|
+
var SWEEP_ABOVE_CLIENTS = 1e4;
|
|
2184
|
+
function msUntilBelow(times, max, now) {
|
|
2185
|
+
if (times.length < max) return 0;
|
|
2186
|
+
return Math.max(0, times[times.length - max] + ATTEMPT_WINDOW_MS - now);
|
|
2187
|
+
}
|
|
2188
|
+
var FailedAttempts = class {
|
|
2189
|
+
constructor(now) {
|
|
2190
|
+
this.now = now;
|
|
2191
|
+
}
|
|
2192
|
+
now;
|
|
2193
|
+
byClient = /* @__PURE__ */ new Map();
|
|
2194
|
+
total = [];
|
|
2195
|
+
lockedFor(client) {
|
|
2196
|
+
const now = this.now();
|
|
2197
|
+
this.total = this.fresh(this.total, now);
|
|
2198
|
+
const mine = this.fresh(this.byClient.get(client) ?? [], now);
|
|
2199
|
+
if (mine.length) this.byClient.set(client, mine);
|
|
2200
|
+
else this.byClient.delete(client);
|
|
2201
|
+
return Math.max(msUntilBelow(mine, MAX_FAILURES_PER_CLIENT, now), msUntilBelow(this.total, MAX_FAILURES_TOTAL, now));
|
|
2202
|
+
}
|
|
2203
|
+
record(client) {
|
|
2204
|
+
const now = this.now();
|
|
2205
|
+
this.total = [...this.fresh(this.total, now), now].slice(-MAX_FAILURES_TOTAL);
|
|
2206
|
+
this.byClient.set(client, [...this.fresh(this.byClient.get(client) ?? [], now), now].slice(-MAX_FAILURES_PER_CLIENT));
|
|
2207
|
+
if (this.byClient.size > SWEEP_ABOVE_CLIENTS) this.sweep(now);
|
|
2208
|
+
}
|
|
2209
|
+
fresh(times, now) {
|
|
2210
|
+
return times.filter((t) => t > now - ATTEMPT_WINDOW_MS);
|
|
2211
|
+
}
|
|
2212
|
+
sweep(now) {
|
|
2213
|
+
for (const [client, times] of this.byClient) {
|
|
2214
|
+
if (!this.fresh(times, now).length) this.byClient.delete(client);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
};
|
|
2218
|
+
|
|
2219
|
+
// src/session/auth.ts
|
|
2178
2220
|
var TUNNEL_COOKIE = "__Host-duto_tunnel";
|
|
2179
2221
|
var SEC_FETCH_SITE_SAME_SITE = "same-site";
|
|
2180
2222
|
var QR_TTL_MS = 5 * 6e4;
|
|
@@ -2189,6 +2231,7 @@ function parseBasic(value) {
|
|
|
2189
2231
|
return m ? splitPair(Buffer.from(m[1], "base64").toString("utf8")) : null;
|
|
2190
2232
|
}
|
|
2191
2233
|
var first2 = (v) => Array.isArray(v) ? v[0] : v;
|
|
2234
|
+
var clientOf2 = (h) => first2(h["cf-connecting-ip"]) ?? "unknown";
|
|
2192
2235
|
var bareHost = (s) => s.replace(/:\d+$/, "").toLowerCase();
|
|
2193
2236
|
function sameHostOrigin(h) {
|
|
2194
2237
|
if (first2(h["sec-fetch-site"])?.toLowerCase() === SEC_FETCH_SITE_SAME_SITE) return false;
|
|
@@ -2210,6 +2253,7 @@ var TunnelAuth = class {
|
|
|
2210
2253
|
now;
|
|
2211
2254
|
secret;
|
|
2212
2255
|
qr = null;
|
|
2256
|
+
failures;
|
|
2213
2257
|
constructor(o) {
|
|
2214
2258
|
this.tunnel = o.tunnel;
|
|
2215
2259
|
this.credentials = o.credentials;
|
|
@@ -2217,6 +2261,7 @@ var TunnelAuth = class {
|
|
|
2217
2261
|
this.now = o.now ?? Date.now;
|
|
2218
2262
|
this.secret = o.secret ?? randomBytes2(32);
|
|
2219
2263
|
this.qrUser = o.credentials[0]?.user ?? "";
|
|
2264
|
+
this.failures = new FailedAttempts(this.now);
|
|
2220
2265
|
}
|
|
2221
2266
|
access(h) {
|
|
2222
2267
|
if (this.allow) {
|
|
@@ -2228,9 +2273,19 @@ var TunnelAuth = class {
|
|
|
2228
2273
|
if (cookieUser && sameHostOrigin(h)) return { kind: "user", user: cookieUser };
|
|
2229
2274
|
const dutoAuthHeader = first2(h["x-duto-auth"]);
|
|
2230
2275
|
const candidate = dutoAuthHeader !== void 0 ? splitPair(dutoAuthHeader) : parseBasic(first2(h.authorization));
|
|
2231
|
-
if (candidate && this.
|
|
2276
|
+
if (candidate && this.attemptPassword(h, candidate.user, candidate.password) === "ok") return { kind: "user", user: candidate.user };
|
|
2232
2277
|
return { kind: "denied" };
|
|
2233
2278
|
}
|
|
2279
|
+
lockedFor(h) {
|
|
2280
|
+
return this.failures.lockedFor(clientOf2(h));
|
|
2281
|
+
}
|
|
2282
|
+
attemptPassword(h, user, password) {
|
|
2283
|
+
const client = clientOf2(h);
|
|
2284
|
+
if (this.failures.lockedFor(client) > 0) return "locked";
|
|
2285
|
+
if (this.checkPassword(user, password)) return "ok";
|
|
2286
|
+
this.failures.record(client);
|
|
2287
|
+
return "wrong";
|
|
2288
|
+
}
|
|
2234
2289
|
checkPassword(user, password) {
|
|
2235
2290
|
let ok2 = false;
|
|
2236
2291
|
for (const c of this.credentials) {
|
|
@@ -2455,6 +2510,10 @@ function safeNext(raw) {
|
|
|
2455
2510
|
if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return "/";
|
|
2456
2511
|
return /^[\x21-\x7e]+$/.test(raw) ? raw : "/";
|
|
2457
2512
|
}
|
|
2513
|
+
var ERROR_TEXT = {
|
|
2514
|
+
wrong: "Wrong username or password.",
|
|
2515
|
+
locked: "Too many wrong passwords. Wait a few minutes and try again."
|
|
2516
|
+
};
|
|
2458
2517
|
function loginPage(o) {
|
|
2459
2518
|
return pageShell(
|
|
2460
2519
|
"Sign in \xB7 duto",
|
|
@@ -2468,7 +2527,7 @@ function loginPage(o) {
|
|
|
2468
2527
|
<input id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
|
|
2469
2528
|
<label for="password">Password</label>
|
|
2470
2529
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
2471
|
-
${o.error ? `<p class="error" role="alert"
|
|
2530
|
+
${o.error ? `<p class="error" role="alert">${ERROR_TEXT[o.error]}</p>
|
|
2472
2531
|
` : ""}<button type="submit">Sign in</button>
|
|
2473
2532
|
</form>
|
|
2474
2533
|
<div class="retry">You stay signed in until you close the browser or the tunnel closes.</div>
|
|
@@ -2584,8 +2643,8 @@ function readForm(req, max) {
|
|
|
2584
2643
|
}
|
|
2585
2644
|
async function authRoute(req, res, route, auth) {
|
|
2586
2645
|
const url = new URL(req.url ?? "/", "http://duto");
|
|
2587
|
-
const page = (status, next, error) => {
|
|
2588
|
-
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
2646
|
+
const page = (status, next, error, extra = {}) => {
|
|
2647
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...extra });
|
|
2589
2648
|
res.end(loginPage({ tunnel: route.tunnel, next, error }));
|
|
2590
2649
|
};
|
|
2591
2650
|
const go = (location, cookie) => {
|
|
@@ -2594,7 +2653,7 @@ async function authRoute(req, res, route, auth) {
|
|
|
2594
2653
|
};
|
|
2595
2654
|
const { pathname } = url;
|
|
2596
2655
|
if (pathname === LOGIN_PATH && req.method === "GET") {
|
|
2597
|
-
page(200, safeNext(url.searchParams.get("next")),
|
|
2656
|
+
page(200, safeNext(url.searchParams.get("next")), null);
|
|
2598
2657
|
return true;
|
|
2599
2658
|
}
|
|
2600
2659
|
if (pathname === LOGIN_PATH && req.method === "POST") {
|
|
@@ -2607,8 +2666,10 @@ async function authRoute(req, res, route, auth) {
|
|
|
2607
2666
|
const form = new URLSearchParams(raw);
|
|
2608
2667
|
const user = form.get("username") ?? "";
|
|
2609
2668
|
const next = safeNext(form.get("next"));
|
|
2610
|
-
|
|
2611
|
-
|
|
2669
|
+
const attempt = auth.attemptPassword(req.headers, user, form.get("password") ?? "");
|
|
2670
|
+
if (attempt === "ok") go(next, auth.loginCookie(user));
|
|
2671
|
+
else if (attempt === "locked") page(429, next, "locked", { "retry-after": String(Math.ceil(auth.lockedFor(req.headers) / 1e3)) });
|
|
2672
|
+
else page(401, next, "wrong");
|
|
2612
2673
|
return true;
|
|
2613
2674
|
}
|
|
2614
2675
|
if (pathname === LOGOUT_PATH && req.method === "GET") {
|
|
@@ -3378,7 +3439,7 @@ async function runSession(specs, source, flags) {
|
|
|
3378
3439
|
}
|
|
3379
3440
|
const warnings = credentialWarnings(specs);
|
|
3380
3441
|
if (wantsUi(flags)) {
|
|
3381
|
-
const { runFullscreen } = await import("./fullscreen-
|
|
3442
|
+
const { runFullscreen } = await import("./fullscreen-ZNFUDV2B.js");
|
|
3382
3443
|
const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update, warnings);
|
|
3383
3444
|
await inspector?.close().catch(() => void 0);
|
|
3384
3445
|
process.exit(code);
|
package/package.json
CHANGED
|
File without changes
|