hop2 0.11.47 → 0.11.49

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/hop CHANGED
@@ -17,6 +17,8 @@ const net = require('net');
17
17
  const WebSocket = require('ws');
18
18
  const { resolveActorFromHeaders, canActorAccessSession } = require('./lib/terminal-access');
19
19
  const { generateSessionName, getThemeChoices, isValidTheme, DEFAULT_THEME } = require('./lib/naming-themes');
20
+ const { AuthSessionStore, isSessionToken: isAuthSessionToken } = require('./lib/auth-sessions');
21
+ const { rotateAuditFile, sweepStaleAuditDirs } = require('./lib/audit-rotation');
20
22
  const {
21
23
  createFailureTracker,
22
24
  createSingleFlight,
@@ -398,7 +400,7 @@ function listPublishTargets(state) {
398
400
  const out = execFileSync(process.execPath, ['-e', `
399
401
  const http = require('http');
400
402
  http.get({ host: '127.0.0.1', port: ${Number(state.port)}, path: '/api/sessions',
401
- headers: { Cookie: 'tunnel_session=' + process.argv[1] } }, (r) => {
403
+ headers: { Authorization: 'Bearer ' + process.argv[1] } }, (r) => {
402
404
  let b = ''; r.on('data', (c) => b += c);
403
405
  r.on('end', () => {
404
406
  try {
@@ -682,6 +684,22 @@ const DOMAIN_CONFIG_FILE = path.join(HOME_DIR, '.domain-config.json');
682
684
  const HOP_CONFIG_FILE = path.join(HOME_DIR, '.config.json');
683
685
  const DEFAULT_PASSWORD_FILE = path.join(HOME_DIR, '.password_hash');
684
686
  const SESSION_COOKIE_NAME = 'tunnel_session';
687
+ // Per-device login sessions (lib/auth-sessions.js). 30 days by default: log
688
+ // in once a month, not once a week — and revoke any one device on its own.
689
+ const AUTH_SESSIONS_FILE = path.join(HOME_DIR, '.auth-sessions.json');
690
+ const AUTH_SESSION_TTL_DAYS = parsePositiveIntEnv('HOP_SESSION_TTL_DAYS', 30);
691
+ const AUTH_SESSION_TTL_MS = AUTH_SESSION_TTL_DAYS * 86400000;
692
+ // Audit log limits: rotate a session's live audit.ndjson past this size,
693
+ // keep this many rotations, and delete a DEAD session's whole log dir after
694
+ // this many days (0 disables the age sweep). Live sessions are never swept.
695
+ const SESSION_AUDIT_MAX_BYTES = parsePositiveIntEnv('HOP_SESSION_AUDIT_MAX_MB', 64) * 1024 * 1024;
696
+ const SESSION_AUDIT_KEEP_ROTATIONS = parsePositiveIntEnv('HOP_SESSION_AUDIT_KEEP', 5);
697
+ const SESSION_AUDIT_RETENTION_DAYS = (() => {
698
+ const raw = process.env.HOP_SESSION_AUDIT_RETENTION_DAYS;
699
+ if (raw === undefined || raw === '') return 90;
700
+ const parsed = Number.parseInt(raw, 10);
701
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 90;
702
+ })();
685
703
  const LOG_DIR = path.join(HOME_DIR, 'logs');
686
704
  const SESSION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
687
705
  const UPLOAD_MAX_BYTES = parsePositiveIntEnv('HOP_UPLOAD_MAX_BYTES', 100 * 1024 * 1024);
@@ -896,6 +914,26 @@ function clearPassword() {
896
914
  function isPasswordEnabled() {
897
915
  return fs.existsSync(PASSWORD_FILE_PATH);
898
916
  }
917
+
918
+ // Remote access policy. A public tunnel URL guarded by a TOTP code alone is
919
+ // one factor. hop requires a password on top before it opens the tunnel,
920
+ // unless the operator opts out explicitly (config allow_totp_only, or
921
+ // HOP_ALLOW_TOTP_ONLY=1). Local-only use (hop local, HOP_NO_TUNNEL) is exempt.
922
+ function isTotpOnlyAllowed() {
923
+ if (process.env.HOP_ALLOW_TOTP_ONLY === '1') return true;
924
+ try { return getHopConfig().allow_totp_only === true; } catch (e) { return false; }
925
+ }
926
+
927
+ function remoteAccessPolicy() {
928
+ if (isPasswordEnabled()) return { ok: true, reason: 'password' };
929
+ if (isTotpOnlyAllowed()) return { ok: true, reason: 'totp-only (opted in)' };
930
+ return {
931
+ ok: false,
932
+ reason: 'no password',
933
+ statusError: 'Tunnel not started: no password set. Run `hop password set`, or `hop config totp-only on` to allow authenticator-only logins.',
934
+ message: `Remote access is off: no password is set. Run ${CYAN}hop password set${RESET} (recommended), or ${CYAN}hop config totp-only on${RESET} to allow authenticator-only logins, then ${CYAN}hop stop && hop start${RESET}.`
935
+ };
936
+ }
899
937
  const LEGACY_SESSION_PREFIXES = ['hop2-'];
900
938
 
901
939
  function stripLegacySessionPrefix(name) {
@@ -2200,11 +2238,14 @@ function getSessionAuditSink(sessionName, workspaceName) {
2200
2238
  const filePath = path.join(sessionDir, 'audit.ndjson');
2201
2239
  ensurePrivateWritableFile(filePath);
2202
2240
  const stream = fs.createWriteStream(filePath, { flags: 'a', mode: PRIVATE_FILE_MODE });
2241
+ let bytes = 0;
2242
+ try { bytes = fs.statSync(filePath).size; } catch (e) { }
2203
2243
  const sink = {
2204
2244
  stream,
2205
2245
  filePath,
2206
2246
  workspaceName: dirs.workspaceName,
2207
- failed: false
2247
+ failed: false,
2248
+ bytes
2208
2249
  };
2209
2250
  stream.on('error', () => {
2210
2251
  if (sink.failed) return;
@@ -2530,8 +2571,41 @@ function appendSessionAuditEvent(sessionName, workspaceName, type, payload = {})
2530
2571
  ...payload
2531
2572
  };
2532
2573
  try {
2533
- sink.stream.write(JSON.stringify(record) + '\n');
2574
+ const line = JSON.stringify(record) + '\n';
2575
+ sink.stream.write(line);
2576
+ sink.bytes += Buffer.byteLength(line, 'utf8');
2577
+ if (sink.bytes >= SESSION_AUDIT_MAX_BYTES) rotateSessionAuditSink(sessionName, sink);
2578
+ } catch (e) { }
2579
+ }
2580
+
2581
+ // Size rotation. Rename FIRST, then end the old stream: on POSIX the open
2582
+ // descriptor follows the renamed file, so whatever is still buffered lands in
2583
+ // the rotated file, and the next append opens a fresh audit.ndjson.
2584
+ function rotateSessionAuditSink(sessionName, sink) {
2585
+ const result = rotateAuditFile(sink.filePath, { keep: SESSION_AUDIT_KEEP_ROTATIONS });
2586
+ sessionAuditSinks.delete(sessionName);
2587
+ try { sink.stream.end(); } catch (e) { }
2588
+ if (result.rotatedTo) {
2589
+ log(`[Audit] Rotated ${sessionName} audit log at ${Math.round(SESSION_AUDIT_MAX_BYTES / 1048576)} MB → ${path.basename(result.rotatedTo)}${result.pruned.length ? ` (pruned ${result.pruned.length})` : ''}`, DIM);
2590
+ }
2591
+ }
2592
+
2593
+ // Age retention: a session that no longer exists keeps its logs for the
2594
+ // retention window, then the whole per-session dir goes. Live sessions never.
2595
+ function sweepSessionAuditLogs() {
2596
+ if (SESSION_AUDIT_RETENTION_DAYS <= 0) return { removed: [], kept: 0 };
2597
+ const roots = [path.join(RUNTIME_DATA_DIR, WORKSPACE_LOGS_DIR_NAME)];
2598
+ try {
2599
+ for (const ws of fs.readdirSync(WORKSPACES_DIR)) roots.push(path.join(WORKSPACES_DIR, ws, WORKSPACE_LOGS_DIR_NAME));
2534
2600
  } catch (e) { }
2601
+ const result = sweepStaleAuditDirs(roots, {
2602
+ retentionMs: SESSION_AUDIT_RETENTION_DAYS * 86400000,
2603
+ isLive: (name) => sessionAuditSinks.has(name) || Boolean(activeSessions[name])
2604
+ });
2605
+ if (result.removed.length) {
2606
+ log(`[Audit] Removed ${result.removed.length} stale session log dir(s) older than ${SESSION_AUDIT_RETENTION_DAYS}d`, DIM);
2607
+ }
2608
+ return result;
2535
2609
  }
2536
2610
 
2537
2611
  function registerSessionClientActor(sessionName, clientId, actor) {
@@ -2991,7 +3065,10 @@ function getRegistrationConfig() {
2991
3065
  const gate = stored.emailGate === 'cloudflare-access' ? 'cloudflare-access' : 'smtp';
2992
3066
  const value = {
2993
3067
  enabled: stored.enabled === true,
2994
- allowedEmailDomain: stored.allowedEmailDomain || 'uchicago.edu',
3068
+ // No default: an admin must say which email domain signups are open to.
3069
+ allowedEmailDomain: typeof stored.allowedEmailDomain === 'string' && stored.allowedEmailDomain.trim()
3070
+ ? stored.allowedEmailDomain.trim().toLowerCase()
3071
+ : null,
2995
3072
  // Where the admin's own hop lives once the bare host becomes the
2996
3073
  // landing page. Purely for the "sign in" link and the approval mail.
2997
3074
  adminSubdomain: stored.adminSubdomain || null,
@@ -3208,7 +3285,7 @@ function renderLandingPage(landingHost, config) {
3208
3285
  const baseDomain = getBaseDomain() || landingHost;
3209
3286
  const domain = escapeHtml(baseDomain);
3210
3287
  const example = escapeHtml(userHostnameFor('you') || `hop-you.${baseDomain}`);
3211
- const emailDomain = escapeHtml(config.allowedEmailDomain);
3288
+ const emailDomain = escapeHtml(config.allowedEmailDomain || '');
3212
3289
  const adminLink = config.adminSubdomain
3213
3290
  ? `<p class="muted">Already have a subdomain? <a href="https://${escapeHtml(userHostnameFor(config.adminSubdomain))}">Sign in</a>.</p>`
3214
3291
  : '';
@@ -4015,6 +4092,7 @@ function loadSessionSecret(existingState) {
4015
4092
  // Generate or load session secret
4016
4093
  const existingState = isServerRunning();
4017
4094
  const SESSION_SECRET = loadSessionSecret(existingState);
4095
+ const authSessions = new AuthSessionStore(AUTH_SESSIONS_FILE, { ttlMs: AUTH_SESSION_TTL_MS });
4018
4096
 
4019
4097
  // Persistence for Stable QR Code
4020
4098
  let AUTH_SECRET;
@@ -9652,21 +9730,97 @@ function appendSetCookie(res, cookies) {
9652
9730
  res.setHeader('Set-Cookie', merged);
9653
9731
  }
9654
9732
 
9655
- // The session cookie, minted in one place. Host-scoped deliberately: adding a
9656
- // Domain would hand this instance's secret to every subdomain of the parent,
9657
- // which on a multi-user host means every other user's machine.
9658
- // (The three login paths below still inline an identical cookie; they predate
9659
- // this helper and were left alone rather than refactoring live auth code.)
9660
- function setAuthCookie(res) {
9733
+ // Is this connection direct from the local machine (CLI, hay, MCP) rather
9734
+ // than proxied in through cloudflared? cloudflared connects from 127.0.0.1
9735
+ // too, but always stamps CF-Connecting-IP on what it forwards.
9736
+ function isDirectLoopbackRequest(req) {
9737
+ if (req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']) return false;
9738
+ const addr = String(req.socket?.remoteAddress || '');
9739
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
9740
+ }
9741
+
9742
+ // Every way a request can prove itself, in one place.
9743
+ // cookie — a per-device login session (browser, hop-ios)
9744
+ // token — Authorization: Bearer <device session token>
9745
+ // ipc — Authorization: Bearer <daemon secret> from a local hop process
9746
+ // (CLI, MCP, hay); also accepted as ?token= on a DIRECT loopback
9747
+ // WebSocket, which is how the local CLI attaches.
9748
+ // The daemon secret is never valid as a cookie: cookies ride the public
9749
+ // tunnel, and one shared value there is exactly what per-device sessions
9750
+ // replace. A stale cookie holding the old secret simply lands on the login page.
9751
+ function authenticateRequest(req, cookies, { queryToken = null } = {}) {
9752
+ const cookieToken = cookies[SESSION_COOKIE_NAME];
9753
+ if (isAuthSessionToken(cookieToken)) {
9754
+ const session = authSessions.verify(cookieToken);
9755
+ if (session) return { ok: true, via: 'cookie', session };
9756
+ }
9757
+ const authHeader = req.headers.authorization;
9758
+ const bearer = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
9759
+ if (bearer) {
9760
+ if (bearer === SESSION_SECRET) return { ok: true, via: 'ipc', session: null };
9761
+ if (isAuthSessionToken(bearer)) {
9762
+ const session = authSessions.verify(bearer);
9763
+ if (session) return { ok: true, via: 'token', session };
9764
+ }
9765
+ }
9766
+ if (typeof queryToken === 'string' && queryToken) {
9767
+ if (queryToken === SESSION_SECRET && isDirectLoopbackRequest(req)) return { ok: true, via: 'ipc', session: null };
9768
+ if (isAuthSessionToken(queryToken)) {
9769
+ const session = authSessions.verify(queryToken);
9770
+ if (session) return { ok: true, via: 'token', session };
9771
+ }
9772
+ }
9773
+ return { ok: false, via: null, session: null };
9774
+ }
9775
+
9776
+ // "iPhone · Safari", "Mac · Chrome": enough to recognise a device in a list.
9777
+ function describeUserAgent(ua) {
9778
+ const s = String(ua || '');
9779
+ if (!s) return null;
9780
+ let device = 'Unknown device';
9781
+ if (/iPhone/i.test(s)) device = 'iPhone';
9782
+ else if (/iPad/i.test(s)) device = 'iPad';
9783
+ else if (/Android/i.test(s)) device = 'Android';
9784
+ else if (/Macintosh|Mac OS X/i.test(s)) device = 'Mac';
9785
+ else if (/Windows/i.test(s)) device = 'Windows';
9786
+ else if (/CrOS/i.test(s)) device = 'ChromeOS';
9787
+ else if (/Linux/i.test(s)) device = 'Linux';
9788
+ let browser = null;
9789
+ if (/HopSpike|hop-ios/i.test(s)) browser = 'hop app';
9790
+ else if (/Edg\//i.test(s)) browser = 'Edge';
9791
+ else if (/OPR\//i.test(s)) browser = 'Opera';
9792
+ else if (/Chrome\//i.test(s) && !/Chromium/i.test(s)) browser = 'Chrome';
9793
+ else if (/Firefox\//i.test(s)) browser = 'Firefox';
9794
+ else if (/Safari\//i.test(s)) browser = 'Safari';
9795
+ else if (/curl|node|python|Go-http/i.test(s)) browser = 'script';
9796
+ return browser ? `${device} · ${browser}` : device;
9797
+ }
9798
+
9799
+ // Mint a per-device session for a login that just succeeded and hand the
9800
+ // browser its cookie. Host-scoped deliberately: adding a Domain would hand
9801
+ // this device's token to every subdomain of the parent, which on a multi-user
9802
+ // host means every other user's machine.
9803
+ function issueAuthSession(req, res, via) {
9804
+ const ua = String(req.headers['user-agent'] || '');
9805
+ const ip = getClientIp(req);
9806
+ const label = describeUserAgent(ua);
9807
+ const created = authSessions.create({
9808
+ via,
9809
+ label,
9810
+ ip,
9811
+ userAgent: ua
9812
+ });
9661
9813
  appendSetCookie(res, [
9662
- cookie.serialize(SESSION_COOKIE_NAME, SESSION_SECRET, {
9814
+ cookie.serialize(SESSION_COOKIE_NAME, created.token, {
9663
9815
  httpOnly: true,
9664
9816
  secure: true,
9665
9817
  sameSite: 'lax',
9666
9818
  path: '/',
9667
- maxAge: 60 * 60 * 24 * 7
9819
+ maxAge: Math.floor(AUTH_SESSION_TTL_MS / 1000)
9668
9820
  })
9669
9821
  ]);
9822
+ log(`[auth] login via ${via} from ${ip} (${label || 'unknown device'}) → session ${created.id}, ${AUTH_SESSION_TTL_DAYS}d`, GREEN);
9823
+ return created;
9670
9824
  }
9671
9825
 
9672
9826
  function clearAuthCookies(res) {
@@ -9706,6 +9860,21 @@ function escapeHtml(value) {
9706
9860
  // per-rpID (per hostname), so a passkey enrolled at the tunnel domain works
9707
9861
  // there and one enrolled at localhost works locally.
9708
9862
  const PASSKEYS_FILE = path.join(HOME_DIR, '.passkeys.json');
9863
+
9864
+ // App IDs allowed to use this host's passkeys natively (Apple webcredentials).
9865
+ // Default: the official hop-ios app, so any hop server works with the App
9866
+ // Store build. Forks and private builds override with HOP_AASA_APP_IDS
9867
+ // (comma-separated TEAMID.bundle.id) or config.json "aasa_app_ids": [...].
9868
+ const DEFAULT_AASA_APP_IDS = ['5AD7QB9795.io.zhoulab.hop.spike'];
9869
+ function getAasaAppIds() {
9870
+ const fromEnv = String(process.env.HOP_AASA_APP_IDS || '').split(',').map((s) => s.trim()).filter(Boolean);
9871
+ if (fromEnv.length) return fromEnv;
9872
+ try {
9873
+ const cfg = getHopConfig();
9874
+ if (Array.isArray(cfg.aasa_app_ids) && cfg.aasa_app_ids.length) return cfg.aasa_app_ids.map(String);
9875
+ } catch (e) { }
9876
+ return DEFAULT_AASA_APP_IDS;
9877
+ }
9709
9878
  let webauthnLibPromise = null;
9710
9879
  function loadWebauthn() {
9711
9880
  // The library is ESM-only; hop is CJS — load lazily via dynamic import.
@@ -9914,14 +10083,11 @@ function startAuthProxy(authEmitter) {
9914
10083
  return rawWriteHead(...args);
9915
10084
  };
9916
10085
 
9917
- // Check auth via cookie OR Authorization header (internal hop-to-hop only)
9918
- // Bearer token is read from state file by other hop processes on same machine
9919
- const authHeader = req.headers.authorization;
9920
- const tokenFromHeader = authHeader && authHeader.startsWith('Bearer ')
9921
- ? authHeader.substring(7)
9922
- : null;
9923
- const isAuthenticated = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET
9924
- || tokenFromHeader === SESSION_SECRET;
10086
+ // Per-device session (cookie / Bearer device token) or the daemon's
10087
+ // own secret as a Bearer from a local hop process. See authenticateRequest.
10088
+ const auth = authenticateRequest(req, cookies);
10089
+ const isAuthenticated = auth.ok;
10090
+ req.hopAuthSession = auth.session;
9925
10091
  // How this request proved itself decides the DEFAULT actor. Humans
9926
10092
  // reach the API through the web UI (cookie) or the hop CLI (which
9927
10093
  // stamps x-hop-via); a raw Bearer-token call with neither is, in
@@ -9929,7 +10095,7 @@ function startAuthProxy(authEmitter) {
9929
10095
  // Those callers rarely remember to declare x-hop-actor, and every
9930
10096
  // one that forgot used to land its sessions in the USER tab
9931
10097
  // (SelectProbe et al). Explicit headers still win outright.
9932
- req.hopAuthVia = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET ? 'cookie' : 'token';
10098
+ req.hopAuthVia = auth.via === 'cookie' ? 'cookie' : 'token';
9933
10099
 
9934
10100
  // API: Auth status (used by local CLI to wait for remote login)
9935
10101
  if (parsedUrl.pathname === '/api/auth' && req.method === 'GET') {
@@ -9943,6 +10109,82 @@ function startAuthProxy(authEmitter) {
9943
10109
  return;
9944
10110
  }
9945
10111
 
10112
+ // API: per-device login sessions — list, revoke, mint device tokens —
10113
+ // and logout (revokes the caller's own session).
10114
+ if (parsedUrl.pathname.startsWith('/api/auth/sessions') || parsedUrl.pathname === '/api/logout') {
10115
+ const respond = (status, payload) => {
10116
+ setNoCache(res);
10117
+ res.writeHead(status, { 'Content-Type': 'application/json' });
10118
+ res.end(JSON.stringify(payload));
10119
+ };
10120
+ if (!isAuthenticated) {
10121
+ respond(401, { ok: false, error: 'Unauthorized' });
10122
+ return;
10123
+ }
10124
+ if (parsedUrl.pathname === '/api/auth/sessions' && req.method === 'GET') {
10125
+ const currentId = auth.session?.id || null;
10126
+ respond(200, {
10127
+ ok: true,
10128
+ current: currentId,
10129
+ via: auth.via,
10130
+ ttlDays: AUTH_SESSION_TTL_DAYS,
10131
+ sessions: authSessions.list().map((s) => ({ ...s, current: s.id === currentId }))
10132
+ });
10133
+ return;
10134
+ }
10135
+ if (parsedUrl.pathname === '/api/auth/sessions/revoke' && req.method === 'POST') {
10136
+ const body = (await readRequestJson(req)) || {};
10137
+ if (body.all === true) {
10138
+ const keep = body.keepCurrent === false ? null : (auth.session?.id || null);
10139
+ const removed = authSessions.revokeAll({ exceptId: keep });
10140
+ log(`[auth] revoked ${removed} login session(s)${keep ? ` (kept ${keep})` : ''}`, YELLOW);
10141
+ respond(200, { ok: true, removed, kept: keep });
10142
+ return;
10143
+ }
10144
+ const id = String(body.id || '');
10145
+ if (!id) {
10146
+ respond(400, { ok: false, error: 'Send { id } or { all: true }' });
10147
+ return;
10148
+ }
10149
+ const removed = authSessions.revoke(id);
10150
+ if (removed) log(`[auth] revoked login session ${id}`, YELLOW);
10151
+ respond(removed ? 200 : 404, { ok: removed, removed: removed ? 1 : 0 });
10152
+ return;
10153
+ }
10154
+ if (parsedUrl.pathname === '/api/auth/sessions/token' && req.method === 'POST') {
10155
+ const body = (await readRequestJson(req)) || {};
10156
+ const days = Number(body.days);
10157
+ const created = authSessions.create({
10158
+ via: 'device-token',
10159
+ label: String(body.label || '').slice(0, 80) || 'device token',
10160
+ ip: getClientIp(req),
10161
+ userAgent: req.headers['user-agent'] || null,
10162
+ ttlMs: Number.isFinite(days) && days > 0 ? days * 86400000 : undefined,
10163
+ noExpiry: body.noExpiry === true
10164
+ });
10165
+ log(`[auth] device token ${created.id} minted (${body.label || 'unlabelled'}, ${created.expiresAt ? `expires ${new Date(created.expiresAt).toISOString().slice(0, 10)}` : 'no expiry'})`, GREEN);
10166
+ respond(200, { ok: true, id: created.id, token: created.token, expiresAt: created.expiresAt });
10167
+ return;
10168
+ }
10169
+ if (parsedUrl.pathname === '/api/logout' && (req.method === 'POST' || req.method === 'GET')) {
10170
+ if (auth.session) {
10171
+ authSessions.revoke(auth.session.id);
10172
+ log(`[auth] logout: session ${auth.session.id}`, YELLOW);
10173
+ }
10174
+ clearAuthCookies(res);
10175
+ if (req.method === 'GET') {
10176
+ setNoCache(res);
10177
+ res.writeHead(302, { Location: '/' });
10178
+ res.end();
10179
+ return;
10180
+ }
10181
+ respond(200, { ok: true });
10182
+ return;
10183
+ }
10184
+ respond(404, { ok: false, error: 'Unknown auth endpoint' });
10185
+ return;
10186
+ }
10187
+
9946
10188
  // Apple App Site Association — the trust anchor for NATIVE passkey
9947
10189
  // sign-in from hop-ios (webcredentials). Apple's CDN fetches this
9948
10190
  // anonymously over HTTPS, so it must sit in front of the auth gate.
@@ -9951,7 +10193,7 @@ function startAuthProxy(authEmitter) {
9951
10193
  if (parsedUrl.pathname === '/.well-known/apple-app-site-association' && req.method === 'GET') {
9952
10194
  res.hopCacheControl = 'public, max-age=300'; // public on purpose — Apple's CDN reads it
9953
10195
  res.writeHead(200, { 'Content-Type': 'application/json' });
9954
- res.end(JSON.stringify({ webcredentials: { apps: ['5AD7QB9795.io.zhoulab.hop.spike'] } }));
10196
+ res.end(JSON.stringify({ webcredentials: { apps: getAasaAppIds() } }));
9955
10197
  return;
9956
10198
  }
9957
10199
 
@@ -10020,7 +10262,7 @@ function startAuthProxy(authEmitter) {
10020
10262
  <p><a href="/">Sign in here instead</a>.</p>`));
10021
10263
  return;
10022
10264
  }
10023
- setAuthCookie(res);
10265
+ issueAuthSession(req, res, 'handoff');
10024
10266
  recordAuth(authEmitter);
10025
10267
  // Keep the token out of the Referer of whatever loads next.
10026
10268
  res.writeHead(302, { Location: '/', 'Referrer-Policy': 'no-referrer' });
@@ -10102,17 +10344,7 @@ function startAuthProxy(authEmitter) {
10102
10344
  cred.lastUsedAt = new Date().toISOString();
10103
10345
  savePasskeys(store);
10104
10346
  resetFailure(ip);
10105
- appendSetCookie(res, [
10106
- cookie.serialize(SESSION_COOKIE_NAME, SESSION_SECRET, {
10107
- httpOnly: true,
10108
- secure: true,
10109
- sameSite: 'lax',
10110
- path: '/',
10111
- maxAge: 60 * 60 * 24 * 7
10112
- })
10113
- ]);
10114
- recordAuth(authEmitter);
10115
- respond(200, { ok: true });
10347
+ issueAuthSession(req, res, 'passkey'); recordAuth(authEmitter); respond(200, { ok: true });
10116
10348
  return;
10117
10349
  }
10118
10350
  // Everything below requires an authenticated session.
@@ -10455,17 +10687,7 @@ happens until someone clicks it.
10455
10687
  // Check TOTP
10456
10688
  if (authenticator.check(totp, AUTH_SECRET)) {
10457
10689
  resetFailure(ip);
10458
- appendSetCookie(res, [
10459
- cookie.serialize(SESSION_COOKIE_NAME, SESSION_SECRET, {
10460
- httpOnly: true,
10461
- secure: true,
10462
- sameSite: 'lax',
10463
- path: '/',
10464
- maxAge: 60 * 60 * 24 * 7
10465
- })
10466
- ]);
10467
- res.writeHead(200, { 'Content-Type': 'application/json' });
10468
- res.end(JSON.stringify({ success: true }));
10690
+ issueAuthSession(req, res, 'totp'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true }));
10469
10691
  recordAuth(authEmitter);
10470
10692
  } else {
10471
10693
  recordFailure(ip);
@@ -10512,16 +10734,7 @@ happens until someone clicks it.
10512
10734
  // Check TOTP
10513
10735
  if (authenticator.check(token, AUTH_SECRET)) {
10514
10736
  resetFailure(ip);
10515
- appendSetCookie(res, [
10516
- cookie.serialize(SESSION_COOKIE_NAME, SESSION_SECRET, {
10517
- httpOnly: true,
10518
- secure: true,
10519
- sameSite: 'lax',
10520
- path: '/',
10521
- maxAge: 60 * 60 * 24 * 7
10522
- })
10523
- ]);
10524
- const redirectTarget = requestedInternalName
10737
+ issueAuthSession(req, res, 'totp'); const redirectTarget = requestedInternalName
10525
10738
  ? buildSessionPath(requestedDisplayName)
10526
10739
  : '/sessions';
10527
10740
  res.writeHead(302, { 'Location': redirectTarget });
@@ -13344,13 +13557,13 @@ happens until someone clicks it.
13344
13557
  const parsedUrl = url.parse(req.url, true);
13345
13558
  const cookies = cookie.parse(req.headers.cookie || '');
13346
13559
 
13347
- // Auth via cookie, Bearer token, or query param (for local CLI connections)
13348
- const authHeader = req.headers.authorization || '';
13349
- const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
13350
- const tokenFromQuery = parsedUrl.query.token;
13351
- const isAuthenticated = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET ||
13352
- bearerToken === SESSION_SECRET ||
13353
- tokenFromQuery === SESSION_SECRET;
13560
+ // Auth via a per-device session (cookie / Bearer / ?token=) or the
13561
+ // daemon secret from a local hop process (Bearer, or ?token= on a
13562
+ // direct loopback connection — how the local CLI attaches).
13563
+ const wsAuth = authenticateRequest(req, cookies, {
13564
+ queryToken: typeof parsedUrl.query.token === 'string' ? parsedUrl.query.token : null
13565
+ });
13566
+ const isAuthenticated = wsAuth.ok;
13354
13567
 
13355
13568
  if (!isAuthenticated) {
13356
13569
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
@@ -13608,6 +13821,12 @@ function startTunnel(isRestart = false) {
13608
13821
  log(`[test] tunnel disabled (HOP_NO_TUNNEL=1) — local stack at http://127.0.0.1:${BASE_PORT}`, YELLOW);
13609
13822
  return Promise.resolve({ tunnel: null, publicUrl: `http://127.0.0.1:${BASE_PORT}` });
13610
13823
  }
13824
+ const policy = remoteAccessPolicy();
13825
+ if (!policy.ok) {
13826
+ log(`[Tunnel] ${policy.message}`, RED);
13827
+ updateTunnelStatus({ connected: false, lastEvent: 'blocked_no_password', lastError: policy.statusError });
13828
+ return Promise.resolve({ tunnel: null, publicUrl: null });
13829
+ }
13611
13830
  const domainConfig = getDomainConfig();
13612
13831
  const useNamedTunnel = domainConfig && domainConfig.tunnelName && domainConfig.hostname;
13613
13832
 
@@ -13927,6 +14146,11 @@ let tunnelRestartQueued = false;
13927
14146
 
13928
14147
  async function startTunnelWithRestart() {
13929
14148
  if (NO_TUNNEL) return;
14149
+ const policy = remoteAccessPolicy();
14150
+ if (!policy.ok) {
14151
+ updateTunnelStatus({ connected: false, lastEvent: 'blocked_no_password', lastError: policy.statusError });
14152
+ return;
14153
+ }
13930
14154
  // Prevent concurrent restarts
13931
14155
  if (tunnelRestartInProgress) {
13932
14156
  tunnelRestartQueued = true;
@@ -14131,6 +14355,23 @@ function stopTunnelHealthMonitor() {
14131
14355
  }
14132
14356
  }
14133
14357
 
14358
+ // The URL + QR banner, or — when the tunnel is blocked by the remote access
14359
+ // policy — the reason and the two ways to lift it.
14360
+ function printTunnelUrlBlock(publicUrl) {
14361
+ if (!publicUrl) {
14362
+ log(`TUNNEL:`, BOLD + YELLOW);
14363
+ const policy = remoteAccessPolicy();
14364
+ log(` ${policy.ok ? 'not connected yet' : policy.message}`, YELLOW);
14365
+ log(` Local sessions work as usual; remote access resumes once the tunnel is up.`, RESET);
14366
+ return;
14367
+ }
14368
+ log(`TUNNEL URL:`, BOLD + GREEN);
14369
+ log(` ${publicUrl}`, GREEN);
14370
+ log(` Scan to open on mobile:`, RESET);
14371
+ // Simple URL QR code - scan with any camera app
14372
+ qrcode.generate(publicUrl, { small: true });
14373
+ }
14374
+
14134
14375
  function waitForStart(authEmitter, publicUrl, label) {
14135
14376
  return new Promise((resolve) => {
14136
14377
  if (process.stdout.isTTY) console.clear();
@@ -14138,12 +14379,7 @@ function waitForStart(authEmitter, publicUrl, label) {
14138
14379
  log(` >>> HOP - TUNNEL SHELL <<<`, BOLD + MAGENTA);
14139
14380
  log(`==================================================\n`, MAGENTA);
14140
14381
 
14141
- log(`TUNNEL URL:`, BOLD + GREEN);
14142
- log(` ${publicUrl}`, GREEN);
14143
- log(` Scan to open on mobile:`, RESET);
14144
-
14145
- // Simple URL QR code - scan with any camera app
14146
- qrcode.generate(publicUrl, { small: true });
14382
+ printTunnelUrlBlock(publicUrl, ' ');
14147
14383
 
14148
14384
  log(`\n--------------------------------------------------`, RESET);
14149
14385
 
@@ -14178,11 +14414,7 @@ function printStartupInfo(publicUrl, label) {
14178
14414
  log(` >>> HOP - TUNNEL SHELL <<<`, BOLD + MAGENTA);
14179
14415
  log(`==================================================\n`, MAGENTA);
14180
14416
 
14181
- log(`TUNNEL URL:`, BOLD + GREEN);
14182
- log(` ${publicUrl}`, GREEN);
14183
- log(` Scan to open on mobile:`, RESET);
14184
-
14185
- qrcode.generate(publicUrl, { small: true });
14417
+ printTunnelUrlBlock(publicUrl);
14186
14418
 
14187
14419
  log(`\n--------------------------------------------------`, RESET);
14188
14420
  printStartupAuthSection(label);
@@ -14198,10 +14430,7 @@ function waitForStartPolling(state, label) {
14198
14430
  log(` >>> HOP - TUNNEL SHELL <<<`, BOLD + MAGENTA);
14199
14431
  log(`==================================================\n`, MAGENTA);
14200
14432
 
14201
- log(`TUNNEL URL:`, BOLD + GREEN);
14202
- log(` ${state.url}`, GREEN);
14203
- log(` Scan to open on mobile:`, RESET);
14204
- qrcode.generate(state.url, { small: true });
14433
+ printTunnelUrlBlock(state.url);
14205
14434
 
14206
14435
  log(`\n--------------------------------------------------`, RESET);
14207
14436
  printStartupAuthSection(label);
@@ -14318,20 +14547,21 @@ async function maybeOfferFirstRunPassword() {
14318
14547
  config.password_offer_shown = true;
14319
14548
  saveHopConfig(config);
14320
14549
 
14321
- log(`\nYour tunnel URL will be protected by an authenticator (TOTP) code only.`, YELLOW);
14322
- log(`A password adds a second factor for remote access.`, RESET);
14550
+ log(`\nRemote access needs a password in addition to the authenticator (TOTP) code.`, YELLOW);
14551
+ log(`Without one, hop runs local-only and the public tunnel stays off`, RESET);
14552
+ log(`(${CYAN}hop config totp-only on${RESET} opts out of that requirement).`, RESET);
14323
14553
 
14324
14554
  const readline = require('readline');
14325
14555
  const stream = require('stream');
14326
14556
 
14327
14557
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
14328
14558
  const answer = await new Promise((resolve) => {
14329
- rl.question(`Set a password for remote access? [y/N] `, resolve);
14559
+ rl.question(`Set a password for remote access now? [Y/n] `, resolve);
14330
14560
  });
14331
14561
  rl.close();
14332
14562
 
14333
- if (!/^y(es)?$/i.test(String(answer || '').trim())) {
14334
- log(`Skipped. You can add one anytime with ${CYAN}hop password set${RESET}.`, RESET);
14563
+ if (/^n(o)?$/i.test(String(answer || '').trim())) {
14564
+ log(`Skipped. The tunnel stays off until ${CYAN}hop password set${RESET} — or allow authenticator-only logins with ${CYAN}hop config totp-only on${RESET}.`, YELLOW);
14335
14565
  return;
14336
14566
  }
14337
14567
 
@@ -15405,7 +15635,7 @@ async function attachToAllSessions(existingTunnel) {
15405
15635
  const KNOWN_COMMANDS = [
15406
15636
  'client', 'start', 'attach', 'local', 'stop', 'status', 'doctor', 'logs',
15407
15637
  'help', 'config', 'url', 'qr', 'health', 'domain', 'domain-clear',
15408
- 'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'recover', 'update', 'resume-waiting', 'claude-hook', 'codex-notify', 'codex-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'whoami', 'new',
15638
+ 'password', 'auth', 'user', 'workspace', 'session', 'wipe', 'restore', 'recover', 'update', 'resume-waiting', 'claude-hook', 'codex-notify', 'codex-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'whoami', 'new',
15409
15639
  'mail', 'registration', 'canonical'
15410
15640
  ];
15411
15641
 
@@ -15477,8 +15707,13 @@ const HELP_SECTIONS = [
15477
15707
  ] },
15478
15708
  { title: 'Security', lines: [
15479
15709
  `hop password Show password status`,
15480
- `hop password set Set or change password`,
15710
+ `hop password set Set or change password (required for the public tunnel)`,
15481
15711
  `hop password clear Remove password protection`,
15712
+ `hop auth List logged-in devices (per-device sessions, 30-day default)`,
15713
+ `hop auth revoke <id>|--all Log one device out, or everyone`,
15714
+ `hop auth token [--label L] Mint a device token for an app or script`,
15715
+ `hop config totp-only on|off Allow the tunnel with authenticator-only login (not recommended)`,
15716
+ `hop config agent-ceiling <mode> Cap the permission mode spawned agents may run with`,
15482
15717
  ] },
15483
15718
  { title: 'Multi-user (admin)', lines: [
15484
15719
  `hop user list List all users`,
@@ -16563,6 +16798,17 @@ async function main() {
16563
16798
  log(` Tunnel: ${state.url || '(unknown)'} ${YELLOW}(status unavailable)${RESET}`, RESET);
16564
16799
  }
16565
16800
 
16801
+ {
16802
+ const policy = remoteAccessPolicy();
16803
+ let loginCount = null;
16804
+ try {
16805
+ const r = await callLocalApi(state, '/api/auth/sessions', 'GET');
16806
+ if (r && r.status === 200) loginCount = (r.data.sessions || []).length;
16807
+ } catch (e) { }
16808
+ log(` Auth: password ${isPasswordEnabled() ? `${GREEN}set${RESET}` : `${YELLOW}not set${RESET}`} · passkeys ${loadPasskeys().credentials.length} · logged-in devices ${loginCount === null ? `${YELLOW}?${RESET}` : loginCount} (${CYAN}hop auth${RESET})`, RESET);
16809
+ if (!policy.ok) log(` ${RED}remote access blocked: ${policy.reason}${RESET} — hop password set, or hop config totp-only on`, RESET);
16810
+ }
16811
+
16566
16812
  const hh = readHayHostStateFile();
16567
16813
  if (hh && hh.pid) {
16568
16814
  let alive = false;
@@ -17031,6 +17277,55 @@ async function main() {
17031
17277
  }
17032
17278
  process.exit(0);
17033
17279
  }
17280
+ // Remote access policy: may the tunnel open with TOTP as the only factor?
17281
+ if (subCmd === 'totp-only') {
17282
+ const value = String(process.argv[4] || '').toLowerCase();
17283
+ const cfg = getHopConfig();
17284
+ if (value === 'on' || value === 'true' || value === '1') {
17285
+ cfg.allow_totp_only = true;
17286
+ saveHopConfig(cfg);
17287
+ log(`Authenticator-only remote login allowed. ${YELLOW}A password is still recommended: hop password set${RESET}`, GREEN);
17288
+ log(`Takes effect on the next daemon start (${CYAN}hop stop && hop start${RESET}).`, DIM);
17289
+ } else if (value === 'off' || value === 'false' || value === '0') {
17290
+ delete cfg.allow_totp_only;
17291
+ saveHopConfig(cfg);
17292
+ log(`Authenticator-only remote login disallowed: the tunnel needs a password (${CYAN}hop password set${RESET}).`, GREEN);
17293
+ log(`Takes effect on the next daemon start (${CYAN}hop stop && hop start${RESET}).`, DIM);
17294
+ } else if (value) {
17295
+ log(`Usage: hop config totp-only on|off`, RED);
17296
+ process.exit(1);
17297
+ } else {
17298
+ const policy = remoteAccessPolicy();
17299
+ log(`TOTP-only remote login: ${isTotpOnlyAllowed() ? `${YELLOW}allowed${RESET}` : 'not allowed (the tunnel needs a password)'}`, RESET);
17300
+ log(`Remote access policy now: ${policy.ok ? `${GREEN}ok${RESET} (${policy.reason})` : `${RED}blocked${RESET} (${policy.reason})`}`, RESET);
17301
+ log(`Change with: ${CYAN}hop config totp-only on|off${RESET}`, DIM);
17302
+ }
17303
+ process.exit(0);
17304
+ }
17305
+ // Ceiling on the permission mode hopx_spawn_agent may give a worker.
17306
+ if (subCmd === 'agent-ceiling') {
17307
+ const modes = ['plan', 'manual', 'acceptEdits', 'dontAsk', 'auto', 'bypassPermissions'];
17308
+ const value = process.argv[4];
17309
+ const cfg = getHopConfig();
17310
+ if (value === 'clear' || value === 'default') {
17311
+ delete cfg.agent_permission_ceiling;
17312
+ saveHopConfig(cfg);
17313
+ log(`Agent permission ceiling reset to the default (bypassPermissions).`, GREEN);
17314
+ } else if (value) {
17315
+ if (!modes.includes(value)) {
17316
+ log(`Unknown mode "${value}". Use one of: ${modes.join(', ')}`, RED);
17317
+ process.exit(1);
17318
+ }
17319
+ cfg.agent_permission_ceiling = value;
17320
+ saveHopConfig(cfg);
17321
+ log(`Spawned agents are capped at ${BOLD}${value}${RESET}; requests above it are clamped.`, GREEN);
17322
+ } else {
17323
+ log(`Agent permission ceiling: ${BOLD}${cfg.agent_permission_ceiling || 'bypassPermissions (default)'}${RESET}`, RESET);
17324
+ log(` Applies to hopx_spawn_agent / hopa spawn (Claude --permission-mode; Codex loses the approval bypass below the top).`, DIM);
17325
+ log(`Change with: ${CYAN}hop config agent-ceiling <mode|clear>${RESET}`, DIM);
17326
+ }
17327
+ process.exit(0);
17328
+ }
17034
17329
  if (subCmd === 'theme' || subCmd === 'naming-theme') {
17035
17330
  const themeArg = process.argv[4];
17036
17331
  if (themeArg) {
@@ -17371,7 +17666,7 @@ async function main() {
17371
17666
  if (!subcommand || subcommand === 'status') {
17372
17667
  log(`\n${BOLD}Self-service registration:${RESET} ${config.enabled ? `${GREEN}on${RESET}` : `${YELLOW}off${RESET}`}`, RESET);
17373
17668
  log(` Landing page: ${landingHost ? `https://${landingHost}` : '(no custom domain — run hop domain <host>)'}${config.landingHost ? ` ${DIM}(separate host; ${escapeHtml((getDomainConfig()||{}).hostname||'the bare domain')} stays your instance)${RESET}` : ''}`, RESET);
17374
- log(` Open to: ${config.allowedEmailDomain} addresses`, RESET);
17669
+ log(` Open to: ${config.allowedEmailDomain ? `${config.allowedEmailDomain} addresses` : `${RED}not set — hop registration domain <email-domain>${RESET}`}`, RESET);
17375
17670
  log(` Admin host: ${config.adminSubdomain ? userHostnameFor(config.adminSubdomain) : '(not set)'}`, RESET);
17376
17671
  if (config.emailGate === 'cloudflare-access') {
17377
17672
  log(` Identity gate: ${GREEN}Cloudflare Access${RESET} (no email sent)`, RESET);
@@ -17393,6 +17688,10 @@ async function main() {
17393
17688
  log(`Set a custom domain first: hop domain <hostname>`, RED);
17394
17689
  process.exit(1);
17395
17690
  }
17691
+ if (!config.allowedEmailDomain) {
17692
+ log(`Say which email domain signups are open to first: hop registration domain <email-domain>`, RED);
17693
+ process.exit(1);
17694
+ }
17396
17695
  if (config.emailGate === 'cloudflare-access') {
17397
17696
  if (!config.accessTeam) {
17398
17697
  log(`Set your Cloudflare Access team first: hop registration access-team <name>`, RED);
@@ -17565,6 +17864,96 @@ async function main() {
17565
17864
  }
17566
17865
 
17567
17866
  // Handle `hop password` commands
17867
+ // Handle `hop auth` — per-device login sessions (list / revoke / device tokens).
17868
+ if (process.argv[2] === 'auth') {
17869
+ const subcommand = process.argv[3];
17870
+ const state = isServerRunning();
17871
+ const requireDaemon = () => {
17872
+ if (!state) {
17873
+ printNotRunningError();
17874
+ process.exit(1);
17875
+ }
17876
+ };
17877
+ const fmtRel = (ts) => {
17878
+ if (!Number.isFinite(ts)) return 'never';
17879
+ const diff = ts - Date.now();
17880
+ const abs = Math.abs(diff);
17881
+ const unit = abs < 3600000 ? [Math.round(abs / 60000), 'm'] : abs < 86400000 ? [Math.round(abs / 3600000), 'h'] : [Math.round(abs / 86400000), 'd'];
17882
+ return diff < 0 ? `${unit[0]}${unit[1]} ago` : `in ${unit[0]}${unit[1]}`;
17883
+ };
17884
+ const flag = (name) => {
17885
+ const i = process.argv.indexOf(name);
17886
+ return i > 0 ? (process.argv[i + 1] || '') : null;
17887
+ };
17888
+
17889
+ if (!subcommand || subcommand === 'sessions' || subcommand === 'list' || subcommand === 'status') {
17890
+ requireDaemon();
17891
+ const r = await callLocalApi(state, '/api/auth/sessions', 'GET');
17892
+ if (!r || r.status !== 200) {
17893
+ log(`Failed to list sessions: ${r?.data?.error || `status ${r?.status}`}`, RED);
17894
+ process.exit(1);
17895
+ }
17896
+ const sessions = r.data.sessions || [];
17897
+ log(`\n${BOLD}Login sessions${RESET} ${sessions.length} active · new logins last ${r.data.ttlDays} days`, CYAN);
17898
+ log(` Password: ${isPasswordEnabled() ? `${GREEN}set${RESET}` : `${YELLOW}not set${RESET}`} Passkeys: ${loadPasskeys().credentials.length} TOTP-only allowed: ${isTotpOnlyAllowed() ? `${YELLOW}yes${RESET}` : 'no'}`, RESET);
17899
+ if (!sessions.length) {
17900
+ log(` (none — nobody is logged in through the tunnel)`, DIM);
17901
+ }
17902
+ for (const s of sessions) {
17903
+ log(` ${s.id} ${String(s.label || '—').padEnd(24).slice(0, 24)} ${String(s.via).padEnd(12)} seen ${fmtRel(s.lastSeenAt).padEnd(9)} ${s.expiresAt ? `expires ${fmtRel(s.expiresAt)}` : 'no expiry'}${s.ip ? ` ${s.ip}` : ''}`, RESET);
17904
+ }
17905
+ log(`\nRevoke one: ${CYAN}hop auth revoke <id>${RESET} everyone: ${CYAN}hop auth revoke --all${RESET} device token: ${CYAN}hop auth token --label "iPhone"${RESET}`, DIM);
17906
+ process.exit(0);
17907
+ }
17908
+
17909
+ if (subcommand === 'revoke' || subcommand === 'logout') {
17910
+ requireDaemon();
17911
+ const target = process.argv[4];
17912
+ if (!target) {
17913
+ log(`Usage: hop auth revoke <session-id> | --all`, RED);
17914
+ process.exit(1);
17915
+ }
17916
+ const body = target === '--all' || target === 'all' ? { all: true, keepCurrent: false } : { id: target };
17917
+ const r = await callLocalApi(state, '/api/auth/sessions/revoke', 'POST', body);
17918
+ if (!r || r.status !== 200) {
17919
+ log(`${r?.status === 404 ? 'No such session' : `Failed: ${r?.data?.error || `status ${r?.status}`}`}`, RED);
17920
+ process.exit(1);
17921
+ }
17922
+ log(`${GREEN}✓ Revoked ${r.data.removed} session(s).${RESET} Those devices see the login page on their next request.`, GREEN);
17923
+ process.exit(0);
17924
+ }
17925
+
17926
+ if (subcommand === 'token') {
17927
+ requireDaemon();
17928
+ const label = flag('--label') || `device token ${new Date().toISOString().slice(0, 10)}`;
17929
+ const daysRaw = flag('--days');
17930
+ const noExpiry = process.argv.includes('--no-expiry');
17931
+ const days = daysRaw ? Number(daysRaw) : null;
17932
+ if (daysRaw && (!Number.isFinite(days) || days <= 0)) {
17933
+ log(`--days must be a positive number`, RED);
17934
+ process.exit(1);
17935
+ }
17936
+ const r = await callLocalApi(state, '/api/auth/sessions/token', 'POST', { label, days: days || undefined, noExpiry });
17937
+ if (!r || r.status !== 200) {
17938
+ log(`Failed: ${r?.data?.error || `status ${r?.status}`}`, RED);
17939
+ process.exit(1);
17940
+ }
17941
+ log(`\n${BOLD}Device token${RESET} ${r.data.id} · ${label} · ${r.data.expiresAt ? `expires ${new Date(r.data.expiresAt).toISOString().slice(0, 10)}` : `${YELLOW}never expires${RESET}`}`, CYAN);
17942
+ log(`\n ${r.data.token}\n`, BOLD);
17943
+ log(`Shown once. Send it as ${CYAN}Authorization: Bearer <token>${RESET} (or the tunnel_session cookie) from the device.`, RESET);
17944
+ log(`Revoke with: ${CYAN}hop auth revoke ${r.data.id}${RESET}`, DIM);
17945
+ process.exit(0);
17946
+ }
17947
+
17948
+ const unknownAuthSub = subcommand && subcommand !== 'help' && subcommand !== '--help';
17949
+ if (unknownAuthSub) log(`Unknown subcommand: ${subcommand}`, RED);
17950
+ log(`\n${BOLD}Login session commands:${RESET}\n`, CYAN);
17951
+ log(` hop auth List devices logged in (per-device sessions)`, RESET);
17952
+ log(` hop auth revoke <id> | --all Log a device out — or everyone`, RESET);
17953
+ log(` hop auth token [--label L] [--days N | --no-expiry] Mint a device token for an app or script`, RESET);
17954
+ process.exit(unknownAuthSub ? 1 : 0);
17955
+ }
17956
+
17568
17957
  if (process.argv[2] === 'password') {
17569
17958
  const subcommand = process.argv[3];
17570
17959
  const readline = require('readline');
@@ -17626,6 +18015,10 @@ async function main() {
17626
18015
  clearPassword();
17627
18016
  log(`${GREEN}✓ Password protection removed.${RESET}`, GREEN);
17628
18017
  log(`\nLogin will only require TOTP code.`, RESET);
18018
+ if (!isTotpOnlyAllowed()) {
18019
+ log(`${YELLOW}The public tunnel will stay off after the next daemon start until a password is set again,${RESET}`, YELLOW);
18020
+ log(`${YELLOW}or you allow authenticator-only logins: ${CYAN}hop config totp-only on${RESET}`, YELLOW);
18021
+ }
17629
18022
  process.exit(0);
17630
18023
  }
17631
18024
 
@@ -18738,6 +19131,15 @@ async function main() {
18738
19131
  setTimeout(runSweep, 60000).unref?.();
18739
19132
  setInterval(runSweep, 180000).unref?.();
18740
19133
 
19134
+ // Audit retention: dead sessions' logs go after the retention window.
19135
+ // Two minutes after boot, then daily. Live sessions are never touched.
19136
+ const runAuditSweep = () => {
19137
+ try { sweepSessionAuditLogs(); } catch (e) { log(`[Audit] sweep error: ${e && e.message}`, YELLOW); }
19138
+ };
19139
+ log(`[Audit] rotation at ${Math.round(SESSION_AUDIT_MAX_BYTES / 1048576)} MB ×${SESSION_AUDIT_KEEP_ROTATIONS}, retention ${SESSION_AUDIT_RETENTION_DAYS > 0 ? `${SESSION_AUDIT_RETENTION_DAYS}d` : 'off'}`, CYAN);
19140
+ setTimeout(runAuditSweep, 120000).unref?.();
19141
+ setInterval(runAuditSweep, 86400000).unref?.();
19142
+
18741
19143
  // Stuck resume prompts are urgent in a way a stale tagline isn't — a
18742
19144
  // session sitting on one is frozen, not just unlabeled — so this runs
18743
19145
  // far more often and starts almost immediately after boot, right when