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/LICENSE +21 -0
- package/README-MCP.md +3 -2
- package/README.md +12 -6
- package/SECURITY.md +102 -0
- package/hop +488 -86
- package/lib/audit-rotation.js +113 -0
- package/lib/auth-sessions.js +194 -0
- package/lib/registration.js +9 -6
- package/mcp/hop-mcp.js +49 -5
- package/package.json +1 -1
- package/tests/audit-rotation.unit.test.cjs +74 -0
- package/tests/auth-sessions.integration.test.cjs +315 -0
- package/tests/auth-sessions.unit.test.cjs +96 -0
- package/tests/hop-mcp.integration.test.cjs +4 -4
- package/tests/registration.integration.test.cjs +3 -1
- package/tests/registration.unit.test.cjs +14 -2
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
9656
|
-
//
|
|
9657
|
-
//
|
|
9658
|
-
|
|
9659
|
-
|
|
9660
|
-
|
|
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,
|
|
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:
|
|
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
|
-
//
|
|
9918
|
-
//
|
|
9919
|
-
const
|
|
9920
|
-
const
|
|
9921
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13348
|
-
|
|
13349
|
-
|
|
13350
|
-
const
|
|
13351
|
-
|
|
13352
|
-
|
|
13353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`\
|
|
14322
|
-
log(`
|
|
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? [
|
|
14559
|
+
rl.question(`Set a password for remote access now? [Y/n] `, resolve);
|
|
14330
14560
|
});
|
|
14331
14561
|
rl.close();
|
|
14332
14562
|
|
|
14333
|
-
if (
|
|
14334
|
-
log(`Skipped.
|
|
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
|