@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
 
@@ -15,7 +15,7 @@ import {
15
15
  tunnelColor,
16
16
  updateLine,
17
17
  validateName
18
- } from "./chunk-DUC4RSBW.js";
18
+ } from "./chunk-QVJHHURM.js";
19
19
 
20
20
  // src/ui/fullscreen.tsx
21
21
  import { render } from "ink";
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  statusColor,
28
28
  updateLine,
29
29
  validateName
30
- } from "./chunk-DUC4RSBW.js";
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.6.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.6.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.checkPassword(candidate.user, candidate.password)) return { kind: "user", user: candidate.user };
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">Wrong username or password.</p>
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")), false);
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
- if (auth.checkPassword(user, form.get("password") ?? "")) go(next, auth.loginCookie(user));
2611
- else page(401, next, true);
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-MSVJF43W.js");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Expose a local port at https://<name>-<user>.duto.dev with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
File without changes