diff --git a/.env.example b/.env.example index d2315df9941..4599a72ef95 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,13 @@ VITE_PROXYSCOTCH_ACCESS_TOKEN= # Set to `true` for subpath based access ENABLE_SUBPATH_BASED_ACCESS=false + +#-----------------------Container Runtime Config---------------------# + +# Caddy's in-container HTTP port (default 80). Set a free port (e.g. 8000) to run +# under a non-root UID like OpenShift (ports < 1024 need root or CAP_NET_BIND_SERVICE), +# and update your published mapping too. Avoid internal ports 8080, 3200, 3000, 3100, +# 3170. Non-root runs expect GID 0. On the AIO image this applies only when +# ENABLE_SUBPATH_BASED_ACCESS=true; the legacy HOPP_AIO_ALTERNATE_PORT is honoured +# there as a fallback. +# HOPP_ALTERNATE_PORT=8000 diff --git a/aio-subpath-access.Caddyfile b/aio-subpath-access.Caddyfile index 4bf8e3b7ac1..1f7befdbda7 100644 --- a/aio-subpath-access.Caddyfile +++ b/aio-subpath-access.Caddyfile @@ -3,7 +3,7 @@ persist_config off } -:{$HOPP_AIO_ALTERNATE_PORT:80} { +:{$HOPP_ALTERNATE_PORT:80} { # Serve the `selfhost-web` SPA by default root * /site/selfhost-web file_server diff --git a/aio_run.mjs b/aio_run.mjs index 605742e083f..40cededddc9 100644 --- a/aio_run.mjs +++ b/aio_run.mjs @@ -1,10 +1,70 @@ #!/usr/local/bin/node // @ts-check -import { execSync, spawn } from "child_process" +import { execFileSync, spawn } from "child_process" import fs from "fs" +import net from "net" +import os from "os" +import path from "path" import process from "process" +// Probe the real bind so the kernel decides (CAP_NET_BIND_SERVICE, port sysctls), +// not a UID guess. +async function assertPortBindable(port) { + await new Promise((resolve) => { + const probe = net.createServer() + probe.once("error", (err) => { + if (err.code === "EACCES") { + console.error(`Cannot bind port ${port} as the current user: set HOPP_ALTERNATE_PORT to a free port >= 1024, or run as root / grant CAP_NET_BIND_SERVICE.`) + process.exit(1) + } + if (err.code === "EADDRINUSE") { + console.error(`Port ${port} is already in use inside the container: set HOPP_ALTERNATE_PORT to a free port.`) + process.exit(1) + } + console.warn(`Skipping bind preflight for port ${port} (${err.code})`) + resolve() + }) + probe.listen(port, () => probe.close(resolve)) + }) +} + +// Empty means unset (compose passes undefined vars as ""), so the :80 default applies. +if (process.env.HOPP_ALTERNATE_PORT === "") delete process.env.HOPP_ALTERNATE_PORT +if (process.env.HOPP_AIO_ALTERNATE_PORT === "") delete process.env.HOPP_AIO_ALTERNATE_PORT + +// Back-compat: fall back to the legacy var when the new one is unset. +const legacyPortApplied = !process.env.HOPP_ALTERNATE_PORT && !!process.env.HOPP_AIO_ALTERNATE_PORT +if (legacyPortApplied) { + process.env.HOPP_ALTERNATE_PORT = process.env.HOPP_AIO_ALTERNATE_PORT +} + +const useSubpathAccess = process.env.ENABLE_SUBPATH_BASED_ACCESS === "true" + +// Sanity-check the value; real bindability is probed below. +const RESERVED_PORTS = ["8080", "3200"] +const altPort = process.env.HOPP_ALTERNATE_PORT +// Name whichever var the operator actually set. +const altPortVar = legacyPortApplied ? "HOPP_AIO_ALTERNATE_PORT" : "HOPP_ALTERNATE_PORT" +if (altPort !== undefined) { + if (!(/^[0-9]+$/.test(altPort) && +altPort >= 1 && +altPort <= 65535)) { + console.error(`${altPortVar}="${altPort}" is invalid: use an integer in 1-65535 (e.g. 8000).`) + process.exit(1) + } + if (RESERVED_PORTS.includes(String(+altPort))) { + console.error(`${altPortVar}="${altPort}" is already used by this image (${RESERVED_PORTS.join(", ")}); pick another port (e.g. 8000).`) + process.exit(1) + } + if (!useSubpathAccess) { + console.warn(`${altPortVar} has no effect in multiport mode (Caddy binds 3000/3100/3170); it applies only when ENABLE_SUBPATH_BASED_ACCESS=true.`) + } +} + +// Only subpath mode binds the configurable port; multiport uses fixed ports. +if (useSubpathAccess) { + await assertPortBindable(+(process.env.HOPP_ALTERNATE_PORT ?? 80)) +} + function runChildProcessWithPrefix(command, args, prefix) { const childProcess = spawn(command, args); @@ -44,30 +104,37 @@ const envFileContent = Object.entries(process.env) }`) .join("\n") -fs.writeFileSync("build.env", envFileContent) - -execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`) +// Write to a temp dir (not cwd) so a non-root UID needn't own the working directory. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hopp-env-")) +const buildEnvPath = path.join(tmpDir, "build.env") -fs.rmSync("build.env") +try { + fs.writeFileSync(buildEnvPath, envFileContent) + // Call the global binary directly (not npx, which needs a writable $HOME cache). + execFileSync("import-meta-env", ["-x", buildEnvPath, "-e", buildEnvPath, "-p", "/site/**/*"], { stdio: "inherit" }) +} finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) +} -const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile' +const caddyFileName = useSubpathAccess ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile' const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy") const backendProcess = runChildProcessWithPrefix("node", ["/dist/backend/dist/src/main.js"], "Backend Server") const webappProcess = runChildProcessWithPrefix("webapp-server", [], "Webapp Server") caddyProcess.on("exit", (code) => { console.log(`Exiting process because Caddy Server exited with code ${code}`) - process.exit(code) + // code is null on signal death; report failure, not success. + process.exit(code ?? 1) }) backendProcess.on("exit", (code) => { console.log(`Exiting process because Backend Server exited with code ${code}`) - process.exit(code) + process.exit(code ?? 1) }) webappProcess.on("exit", (code) => { console.log(`Exiting process because Webapp Server exited with code ${code}`) - process.exit(code) + process.exit(code ?? 1) }) process.on('SIGINT', () => { diff --git a/docker-compose.yml b/docker-compose.yml index 6f103935e16..c0485a5c953 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,7 +51,7 @@ services: hoppscotch-db: condition: service_healthy ports: - - "3180:80" + - "3180:${HOPP_ALTERNATE_PORT:-80}" - "3170:3170" # The main hoppscotch app with integrated webapp server. This will be hosted at port 3000 @@ -70,7 +70,7 @@ services: depends_on: - hoppscotch-backend ports: - - "3080:80" + - "3080:${HOPP_ALTERNATE_PORT:-80}" - "3000:3000" - "3200:3200" @@ -89,7 +89,7 @@ services: depends_on: - hoppscotch-backend ports: - - "3280:80" + - "3280:${HOPP_ALTERNATE_PORT:-80}" - "3100:3100" # The service that spins up all services at once in one container @@ -111,7 +111,7 @@ services: - "3100:3100" - "3170:3170" - "3200:3200" - - "3080:80" + - "3080:${HOPP_ALTERNATE_PORT:-80}" # Profile with no database dependency (purely developmental) hoppscotch-aio-no-db: @@ -129,7 +129,7 @@ services: - "3100:3100" - "3170:3170" - "3200:3200" - - "3080:80" + - "3080:${HOPP_ALTERNATE_PORT:-80}" # The preset DB service, you can delete/comment the below lines if # you are using an external postgres instance diff --git a/healthcheck.sh b/healthcheck.sh index 8777c74d872..9ccd5786fb4 100644 --- a/healthcheck.sh +++ b/healthcheck.sh @@ -17,7 +17,7 @@ if [ "$UPTIME" -lt 15 ]; then fi if [ "$ENABLE_SUBPATH_BASED_ACCESS" = "true" ]; then - curlCheck "http://localhost:${HOPP_AIO_ALTERNATE_PORT:-80}/backend/ping" || exit 1 + curlCheck "http://localhost:${HOPP_ALTERNATE_PORT:-${HOPP_AIO_ALTERNATE_PORT:-80}}/backend/ping" || exit 1 else curlCheck "http://localhost:3000" || exit 1 curlCheck "http://localhost:3100" || exit 1 diff --git a/packages/hoppscotch-agent/package.json b/packages/hoppscotch-agent/package.json index 5c9dd6f2200..752d4821976 100644 --- a/packages/hoppscotch-agent/package.json +++ b/packages/hoppscotch-agent/package.json @@ -1,7 +1,7 @@ { "name": "hoppscotch-agent", "private": true, - "version": "0.1.17", + "version": "0.1.18", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/hoppscotch-agent/src-tauri/Cargo.lock b/packages/hoppscotch-agent/src-tauri/Cargo.lock index fd52c1d2c1f..cb3117751f1 100644 --- a/packages/hoppscotch-agent/src-tauri/Cargo.lock +++ b/packages/hoppscotch-agent/src-tauri/Cargo.lock @@ -2070,7 +2070,7 @@ dependencies = [ [[package]] name = "hoppscotch-agent" -version = "0.1.17" +version = "0.1.18" dependencies = [ "aes-gcm", "axum", @@ -4203,9 +4203,10 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "relay" version = "0.1.1" -source = "git+https://github.com/CuriousCorrelation/relay.git#ed2329e4ebb71bb984c4705aa950cb9c3f9ff931" +source = "git+https://github.com/CuriousCorrelation/relay.git?rev=1c4f1e16106ed48983926ea6ddb114c1dbb2f688#1c4f1e16106ed48983926ea6ddb114c1dbb2f688" dependencies = [ "bytes", + "cookie", "curl", "dashmap", "env_logger", diff --git a/packages/hoppscotch-agent/src-tauri/Cargo.toml b/packages/hoppscotch-agent/src-tauri/Cargo.toml index c8809b0ca3a..cf1e2cc03ad 100644 --- a/packages/hoppscotch-agent/src-tauri/Cargo.toml +++ b/packages/hoppscotch-agent/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hoppscotch-agent" -version = "0.1.17" +version = "0.1.18" description = "A cross-platform HTTP request agent for Hoppscotch for advanced request handling including custom headers, certificates, proxies, and local system integration." authors = ["AndrewBastin", "CuriousCorrelation"] edition = "2021" @@ -32,7 +32,7 @@ rand = "0.8.5" tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json", "fmt", "std", "time"] } tracing-appender = "0.2.3" -relay = { git = "https://github.com/CuriousCorrelation/relay.git" } +relay = { git = "https://github.com/CuriousCorrelation/relay.git", rev = "1c4f1e16106ed48983926ea6ddb114c1dbb2f688" } thiserror = "1.0.69" tauri-plugin-store = "2.4.1" x25519-dalek = { version = "2.0.1", features = ["getrandom"] } diff --git a/packages/hoppscotch-agent/src-tauri/tauri.conf.json b/packages/hoppscotch-agent/src-tauri/tauri.conf.json index d23437e4440..8a537b65315 100644 --- a/packages/hoppscotch-agent/src-tauri/tauri.conf.json +++ b/packages/hoppscotch-agent/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2.0.0-rc", "productName": "Hoppscotch Agent", - "version": "0.1.17", + "version": "0.1.18", "identifier": "io.hoppscotch.agent", "build": { "beforeDevCommand": "pnpm dev", diff --git a/packages/hoppscotch-backend/backend.Caddyfile b/packages/hoppscotch-backend/backend.Caddyfile index 4c6599c4359..ac7785d9fb5 100644 --- a/packages/hoppscotch-backend/backend.Caddyfile +++ b/packages/hoppscotch-backend/backend.Caddyfile @@ -3,7 +3,7 @@ persist_config off } -:80 :3170 { +:{$HOPP_ALTERNATE_PORT:80} :3170 { @mock { header_regexp host Host ^[^.]+\.mock\..*$ } diff --git a/packages/hoppscotch-backend/package.json b/packages/hoppscotch-backend/package.json index 42e60d880b1..f34ab149cce 100644 --- a/packages/hoppscotch-backend/package.json +++ b/packages/hoppscotch-backend/package.json @@ -1,6 +1,6 @@ { "name": "hoppscotch-backend", - "version": "2026.6.0", + "version": "2026.6.1", "description": "", "author": "", "private": true, diff --git a/packages/hoppscotch-backend/prod_run.mjs b/packages/hoppscotch-backend/prod_run.mjs index 5aa77482342..f99b5a8951b 100644 --- a/packages/hoppscotch-backend/prod_run.mjs +++ b/packages/hoppscotch-backend/prod_run.mjs @@ -2,8 +2,50 @@ // @ts-check import { spawn } from 'child_process'; +import net from 'net'; import process from 'process'; +// Probe the real bind so the kernel decides (CAP_NET_BIND_SERVICE, port sysctls), +// not a UID guess. +async function assertPortBindable(port) { + await new Promise((resolve) => { + const probe = net.createServer(); + probe.once('error', (err) => { + if (err.code === 'EACCES') { + console.error(`Cannot bind port ${port} as the current user: set HOPP_ALTERNATE_PORT to a free port >= 1024, or run as root / grant CAP_NET_BIND_SERVICE.`); + process.exit(1); + } + if (err.code === 'EADDRINUSE') { + console.error(`Port ${port} is already in use inside the container: set HOPP_ALTERNATE_PORT to a free port.`); + process.exit(1); + } + console.warn(`Skipping bind preflight for port ${port} (${err.code})`); + resolve(); + }); + probe.listen(port, () => probe.close(resolve)); + }); +} + +// Empty means unset (compose passes undefined vars as ""), so the :80 default applies. +if (process.env.HOPP_ALTERNATE_PORT === '') delete process.env.HOPP_ALTERNATE_PORT; + +// Sanity-check the value; real bindability is probed below. +const RESERVED_PORTS = ['3170', '8080']; +const altPort = process.env.HOPP_ALTERNATE_PORT; +if (altPort !== undefined) { + if (!(/^[0-9]+$/.test(altPort) && +altPort >= 1 && +altPort <= 65535)) { + console.error(`HOPP_ALTERNATE_PORT="${altPort}" is invalid: use an integer in 1-65535 (e.g. 8000).`); + process.exit(1); + } + if (RESERVED_PORTS.includes(String(+altPort))) { + console.error(`HOPP_ALTERNATE_PORT="${altPort}" is already used by this image (${RESERVED_PORTS.join(', ')}); pick another port (e.g. 8000).`); + process.exit(1); + } +} + +// Caddy always binds this port (env value or the :80 default). +await assertPortBindable(+(process.env.HOPP_ALTERNATE_PORT ?? 80)); + function runChildProcessWithPrefix(command, args, prefix) { const childProcess = spawn(command, args); @@ -46,14 +88,15 @@ const backendProcess = runChildProcessWithPrefix( caddyProcess.on('exit', (code) => { console.log(`Exiting process because Caddy Server exited with code ${code}`); - process.exit(code); + // code is null on signal death; report failure, not success. + process.exit(code ?? 1); }); backendProcess.on('exit', (code) => { console.log( `Exiting process because Backend Server exited with code ${code}`, ); - process.exit(code); + process.exit(code ?? 1); }); process.on('SIGINT', () => { diff --git a/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts index 3b9b97c52a6..a5d8dc5cf6b 100644 --- a/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts +++ b/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts @@ -21,6 +21,16 @@ import { /** * Extracts an access token from a cookie in the request. * + * A blank cookie is treated as absent so `extractToken` uses the + * `Authorization` header. `O.fromNullable` alone keeps `''` as `Some('')`, + * and a whitespace-only value (`access_token= `) is non-empty by length, + * so both would be read in preference to a valid bearer token and reject + * the request with an unusable credential. A real JWT has no whitespace, + * so the trim-and-length check drops both. + * + * The value may not be a string: `cookie-parser` JSON-decodes `j:`-prefixed + * cookies into objects, where calling `.trim()` throws before auth fallback. + * * @param request - Express Request object * @returns Option containing the token if found */ @@ -28,6 +38,10 @@ const extractFromCookie = (request: Request): O.Option => pipe( O.fromNullable(request.cookies), O.chain((cookies) => O.fromNullable(cookies['access_token'])), + O.filter( + (token): token is string => + typeof token === 'string' && token.trim().length > 0, + ), ); /** diff --git a/packages/hoppscotch-common/package.json b/packages/hoppscotch-common/package.json index 5955d76b93d..5f909fce26c 100644 --- a/packages/hoppscotch-common/package.json +++ b/packages/hoppscotch-common/package.json @@ -1,7 +1,7 @@ { "name": "@hoppscotch/common", "private": true, - "version": "2026.6.0", + "version": "2026.6.1", "scripts": { "dev": "pnpm exec npm-run-all -p -l dev:*", "test": "vitest --run", diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/JSONLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/JSONLensRenderer.vue index 20165bea669..9a21ec80197 100644 --- a/packages/hoppscotch-common/src/components/lenses/renderers/JSONLensRenderer.vue +++ b/packages/hoppscotch-common/src/components/lenses/renderers/JSONLensRenderer.vue @@ -273,7 +273,6 @@ import * as E from "fp-ts/Either" import { pipe } from "fp-ts/function" import { computed, ref, reactive } from "vue" import { computedAsync, refDebounced } from "@vueuse/core" -import * as jq from "jq-wasm" import { useCodemirror } from "@composables/codemirror" import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" import jsonParse, { JSONObjectMember, JSONValue } from "~/helpers/jsonParse" @@ -297,6 +296,24 @@ import { useScrollerRef } from "~/composables/useScrollerRef" const t = useI18n() +// `jq-wasm` bundles a sizeable (1MB+) WASM/Emscripten payload that's only +// ever needed when the user actively filters a JSON response with a jq +// query. Importing it lazily keeps that payload out of the chunk every +// JSON response render has to load and parse. +let jqWasmPromise: Promise | null = null +const getJqWasm = () => { + if (!jqWasmPromise) { + // if the dynamic import fails (e.g. a transient network error), drop the + // cached promise so the next filter attempt can retry instead of being + // stuck failing until the page is reloaded + jqWasmPromise = import("jq-wasm").catch((err) => { + jqWasmPromise = null + throw err + }) + } + return jqWasmPromise +} + const props = defineProps<{ response: HoppRESTResponse | HoppRESTRequestResponse isSavable: boolean @@ -382,6 +399,7 @@ const jsonResponseBodyText = computedAsync( const input = JSON.parse( LJSON.stringify(responseJsonObject.value.right as any) || "{}" ) + const jq = await getJqWasm() const { exitCode, stdout, stderr } = await jq.raw( input, debouncedFilterQuery.value diff --git a/packages/hoppscotch-common/src/platform/instance.ts b/packages/hoppscotch-common/src/platform/instance.ts index dbcdacf7ec1..9d96aeef9ef 100644 --- a/packages/hoppscotch-common/src/platform/instance.ts +++ b/packages/hoppscotch-common/src/platform/instance.ts @@ -18,7 +18,7 @@ export const VENDORED_INSTANCE_CONFIG: Instance = { kind: "vendored" as const, serverUrl: "app://hoppscotch", displayName: "Hoppscotch Desktop", - version: "26.6.0", + version: "26.6.1", lastUsed: new Date().toISOString(), bundleName: "Hoppscotch", } diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts index feef9036480..78aec496f72 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts @@ -126,7 +126,20 @@ export class AgentKernelInterceptorService const effectiveRequest = this.store.completeRequest( preProcessRelayRequest(request) ) - await this.cookieJar.applyCookiesToRequest(effectiveRequest) + + // A caller opts a request out of the shared cookie jar by setting + // `meta.options.cookies` to false. The desktop auth module sets it + // on its own bearer-authenticated backend calls so the interceptor + // skips both attaching a captured auth cookie to them and capturing + // one from their responses. Without that, a stale or blank + // `access_token` cookie was read in preference to the bearer token + // and desktop login stalled. Read from the original request because + // `completeRequest` rebuilds `meta` from domain settings. + const useCookieJar = request.meta?.options?.cookies !== false + + if (useCookieJar) { + await this.cookieJar.applyCookiesToRequest(effectiveRequest) + } const existingUserAgentHeader = Object.keys( effectiveRequest.headers || {} @@ -204,10 +217,12 @@ export class AgentKernelInterceptorService multiHeaders: multiHeaders.length > 0 ? multiHeaders : undefined, } - await this.cookieJar.captureResponseCookies( - transformedResponse, - effectiveRequest.url - ) + if (useCookieJar) { + await this.cookieJar.captureResponseCookies( + transformedResponse, + effectiveRequest.url + ) + } return E.right(transformedResponse) } catch (e) { diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts index b3feeaec494..8987abd27de 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts @@ -185,7 +185,19 @@ export class NativeKernelInterceptorService preProcessRelayRequest(request) ) - await this.cookieJar.applyCookiesToRequest(effectiveRequest) + // A caller opts a request out of the shared cookie jar by setting + // `meta.options.cookies` to false. The desktop auth module sets it + // on its own bearer-authenticated backend calls so the interceptor + // skips both attaching a captured auth cookie to them and capturing + // one from their responses. Without that, a stale or blank + // `access_token` cookie was read in preference to the bearer token + // and desktop login stalled. Read from the original request because + // `completeRequest` rebuilds `meta` from domain settings. + const useCookieJar = request.meta?.options?.cookies !== false + + if (useCookieJar) { + await this.cookieJar.applyCookiesToRequest(effectiveRequest) + } const existingUserAgentHeader = Object.keys( effectiveRequest.headers || {} @@ -212,7 +224,7 @@ export class NativeKernelInterceptorService setRelayExecution(relayExecution) const relayResponse = await relayExecution.response - if (E.isRight(relayResponse)) { + if (E.isRight(relayResponse) && useCookieJar) { await this.cookieJar.captureResponseCookies( relayResponse.right, effectiveRequest.url diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts index 00c9217cf32..e331abb36a0 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts @@ -245,7 +245,12 @@ export class ProxyKernelInterceptorService // Same shared send path as native and agent. proxyscotch returns // Set-Cookie as a header string rather than structured cookies, so // receive-side capture for the proxy path is a separate follow-up. - await this.cookieJar.applyCookiesToRequest(processedRequest) + // A caller opts out of the jar by setting `meta.options.cookies` to + // false, so the interceptor skips attaching a captured auth cookie + // to the desktop auth module's bearer-authenticated backend calls. + if (request.meta?.options?.cookies !== false) { + await this.cookieJar.applyCookiesToRequest(processedRequest) + } let content: ContentType const multipartKey = `proxyRequestData-${v4()}` diff --git a/packages/hoppscotch-desktop/package.json b/packages/hoppscotch-desktop/package.json index 0e982817860..2e3af6673dd 100644 --- a/packages/hoppscotch-desktop/package.json +++ b/packages/hoppscotch-desktop/package.json @@ -1,7 +1,7 @@ { "name": "hoppscotch-desktop", "private": true, - "version": "26.6.0", + "version": "26.6.1", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.lock b/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.lock index 32eaf0897c0..20a9e337cb1 100644 --- a/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.lock +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.lock @@ -168,7 +168,7 @@ checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "curl" version = "0.4.47" -source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git?rev=1ec8079cf527b9cf47cc7a48c68b458affdae273#1ec8079cf527b9cf47cc7a48c68b458affdae273" dependencies = [ "curl-sys", "libc", @@ -180,7 +180,7 @@ dependencies = [ [[package]] name = "curl-sys" version = "0.4.77+curl-8.10.1" -source = "git+https://github.com/CuriousCorrelation/curl-rust.git#1ec8079cf527b9cf47cc7a48c68b458affdae273" +source = "git+https://github.com/CuriousCorrelation/curl-rust.git?rev=1ec8079cf527b9cf47cc7a48c68b458affdae273#1ec8079cf527b9cf47cc7a48c68b458affdae273" dependencies = [ "cc", "libc", diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.toml b/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.toml index a274b616117..a781adfa32b 100644 --- a/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.toml +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/Cargo.toml @@ -6,7 +6,7 @@ authors = ["CuriousCorrelation"] edition = "2021" [dependencies] -curl = { git = "https://github.com/CuriousCorrelation/curl-rust.git", features = ["ntlm"] } +curl = { git = "https://github.com/CuriousCorrelation/curl-rust.git", rev = "1ec8079cf527b9cf47cc7a48c68b458affdae273", features = ["ntlm"] } cookie = "0.18" tokio-util = "0.7.12" lazy_static = "1.5.0" diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.lock b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.lock index 141ace18506..f3eac4408ec 100644 --- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.lock +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.lock @@ -2931,9 +2931,10 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relay" version = "0.1.1" -source = "git+https://github.com/CuriousCorrelation/relay.git#ed2329e4ebb71bb984c4705aa950cb9c3f9ff931" +source = "git+https://github.com/CuriousCorrelation/relay.git?rev=1c4f1e16106ed48983926ea6ddb114c1dbb2f688#1c4f1e16106ed48983926ea6ddb114c1dbb2f688" dependencies = [ "bytes", + "cookie", "curl", "dashmap", "env_logger", diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.toml b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.toml index 00f2950c5d9..e1869696907 100644 --- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.toml +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.toml @@ -13,7 +13,7 @@ tauri = { version = "2.1.0" } serde = "1.0" thiserror = "2" tracing = "0.1.41" -relay = { git = "https://github.com/CuriousCorrelation/relay.git" } +relay = { git = "https://github.com/CuriousCorrelation/relay.git", rev = "1c4f1e16106ed48983926ea6ddb114c1dbb2f688" } [build-dependencies] tauri-plugin = { version = "2.5.4", features = ["build"] } diff --git a/packages/hoppscotch-desktop/src-tauri/Cargo.lock b/packages/hoppscotch-desktop/src-tauri/Cargo.lock index 7127ee3105a..9cc884f6d89 100644 --- a/packages/hoppscotch-desktop/src-tauri/Cargo.lock +++ b/packages/hoppscotch-desktop/src-tauri/Cargo.lock @@ -2324,7 +2324,7 @@ dependencies = [ [[package]] name = "hoppscotch-desktop" -version = "26.6.0" +version = "26.6.1" dependencies = [ "axum", "dirs 6.0.0", diff --git a/packages/hoppscotch-desktop/src-tauri/Cargo.toml b/packages/hoppscotch-desktop/src-tauri/Cargo.toml index 7e114483f51..ebc5831b985 100644 --- a/packages/hoppscotch-desktop/src-tauri/Cargo.toml +++ b/packages/hoppscotch-desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hoppscotch-desktop" -version = "26.6.0" +version = "26.6.1" description = "Desktop App for hoppscotch.io" authors = ["CuriousCorrelation"] edition = "2021" diff --git a/packages/hoppscotch-desktop/src-tauri/tauri.conf.json b/packages/hoppscotch-desktop/src-tauri/tauri.conf.json index d1614ea11ae..4607b3d4955 100644 --- a/packages/hoppscotch-desktop/src-tauri/tauri.conf.json +++ b/packages/hoppscotch-desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Hoppscotch", - "version": "26.6.0", + "version": "26.6.1", "identifier": "io.hoppscotch.desktop", "build": { "beforeDevCommand": "pnpm dev", diff --git a/packages/hoppscotch-desktop/src-tauri/tauri.portable.macos.conf.json b/packages/hoppscotch-desktop/src-tauri/tauri.portable.macos.conf.json index 5cf9a7ad8dc..315536b2744 100644 --- a/packages/hoppscotch-desktop/src-tauri/tauri.portable.macos.conf.json +++ b/packages/hoppscotch-desktop/src-tauri/tauri.portable.macos.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Hoppscotch", - "version": "26.6.0", + "version": "26.6.1", "identifier": "io.hoppscotch.desktop", "build": { "beforeDevCommand": "pnpm dev", diff --git a/packages/hoppscotch-desktop/src-tauri/tauri.portable.windows.conf.json b/packages/hoppscotch-desktop/src-tauri/tauri.portable.windows.conf.json index 15ff044ae00..70c08d678a2 100644 --- a/packages/hoppscotch-desktop/src-tauri/tauri.portable.windows.conf.json +++ b/packages/hoppscotch-desktop/src-tauri/tauri.portable.windows.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Hoppscotch", - "version": "26.6.0", + "version": "26.6.1", "identifier": "io.hoppscotch.desktop", "build": { "beforeDevCommand": "pnpm dev", diff --git a/packages/hoppscotch-desktop/src/views/Home.vue b/packages/hoppscotch-desktop/src/views/Home.vue index 16e2a374392..6b9e29633fc 100644 --- a/packages/hoppscotch-desktop/src/views/Home.vue +++ b/packages/hoppscotch-desktop/src/views/Home.vue @@ -307,7 +307,7 @@ const loadVendored = async () => { const vendoredInstance: VendoredInstance = { type: "vendored", displayName: "Hoppscotch", - version: "26.6.0", + version: "26.6.1", } const connectionState: ConnectionState = { diff --git a/packages/hoppscotch-selfhost-web/package.json b/packages/hoppscotch-selfhost-web/package.json index 3cdcec3b98b..fdbcff33b1e 100644 --- a/packages/hoppscotch-selfhost-web/package.json +++ b/packages/hoppscotch-selfhost-web/package.json @@ -1,7 +1,7 @@ { "name": "@hoppscotch/selfhost-web", "private": true, - "version": "2026.6.0", + "version": "2026.6.1", "type": "module", "scripts": { "dev:vite": "vite", diff --git a/packages/hoppscotch-selfhost-web/prod_run.mjs b/packages/hoppscotch-selfhost-web/prod_run.mjs index c50f54592a9..0885f501f13 100755 --- a/packages/hoppscotch-selfhost-web/prod_run.mjs +++ b/packages/hoppscotch-selfhost-web/prod_run.mjs @@ -1,6 +1,51 @@ #!/usr/local/bin/node -import { execSync } from "child_process" +import { execFileSync } from "child_process" import fs from "fs" +import net from "net" +import os from "os" +import path from "path" + +// Probe the real bind so the kernel decides (CAP_NET_BIND_SERVICE, port sysctls), +// not a UID guess. +async function assertPortBindable(port) { + await new Promise((resolve) => { + const probe = net.createServer() + probe.once("error", (err) => { + if (err.code === "EACCES") { + console.error(`Cannot bind port ${port} as the current user: set HOPP_ALTERNATE_PORT to a free port >= 1024, or run as root / grant CAP_NET_BIND_SERVICE.`) + process.exit(1) + } + if (err.code === "EADDRINUSE") { + console.error(`Port ${port} is already in use inside the container: set HOPP_ALTERNATE_PORT to a free port.`) + process.exit(1) + } + console.warn(`Skipping bind preflight for port ${port} (${err.code})`) + resolve() + }) + probe.listen(port, () => probe.close(resolve)) + }) +} + +// Empty means unset (compose passes undefined vars as ""), so the :80 default +// applies. The image CMD also unsets an empty value before launching Caddy. +if (process.env.HOPP_ALTERNATE_PORT === "") delete process.env.HOPP_ALTERNATE_PORT + +// Sanity-check the value; real bindability is probed below. +const RESERVED_PORTS = ["3000", "3200"] +const altPort = process.env.HOPP_ALTERNATE_PORT +if (altPort !== undefined) { + if (!(/^[0-9]+$/.test(altPort) && +altPort >= 1 && +altPort <= 65535)) { + console.error(`HOPP_ALTERNATE_PORT="${altPort}" is invalid: use an integer in 1-65535 (e.g. 8000).`) + process.exit(1) + } + if (RESERVED_PORTS.includes(String(+altPort))) { + console.error(`HOPP_ALTERNATE_PORT="${altPort}" is already used by this image (${RESERVED_PORTS.join(", ")}); pick another port (e.g. 8000).`) + process.exit(1) + } +} + +// Caddy always binds this port (env value or the :80 default). +await assertPortBindable(+(process.env.HOPP_ALTERNATE_PORT ?? 80)) const envFileContent = Object.entries(process.env) .filter(([env]) => env.startsWith("VITE_")) @@ -11,8 +56,14 @@ const envFileContent = Object.entries(process.env) ) .join("\n") -fs.writeFileSync("build.env", envFileContent) - -execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`) +// Write to a temp dir (not cwd) so a non-root UID needn't own the working directory. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hopp-env-")) +const buildEnvPath = path.join(tmpDir, "build.env") -fs.rmSync("build.env") +try { + fs.writeFileSync(buildEnvPath, envFileContent) + // Call the global binary directly (not npx, which needs a writable $HOME cache). + execFileSync("import-meta-env", ["-x", buildEnvPath, "-e", buildEnvPath, "-p", "/site/**/*"], { stdio: "inherit" }) +} finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) +} diff --git a/packages/hoppscotch-selfhost-web/selfhost-web.Caddyfile b/packages/hoppscotch-selfhost-web/selfhost-web.Caddyfile index 59e76d81df2..36912f8609e 100644 --- a/packages/hoppscotch-selfhost-web/selfhost-web.Caddyfile +++ b/packages/hoppscotch-selfhost-web/selfhost-web.Caddyfile @@ -3,7 +3,7 @@ persist_config off } -:80 :3000 { +:{$HOPP_ALTERNATE_PORT:80} :3000 { try_files {path} / root * /site/selfhost-web file_server diff --git a/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts b/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts index c590efb8a1c..22d503c57bc 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts @@ -12,6 +12,7 @@ import { parseBodyAsJSON } from "@hoppscotch/common/helpers/functional/json" import { AuthEvent, AuthPlatformDef } from "@hoppscotch/common/platform/auth" import { PersistenceService } from "@hoppscotch/common/services/persistence" import { KernelInterceptorService } from "@hoppscotch/common/services/kernel-interceptor.service" +import { CookieJarService } from "@hoppscotch/common/services/cookie-jar.service" import Login from "@app/components/Login.vue" import { getAllowedAuthProviders, updateUserDisplayName } from "./api" @@ -46,6 +47,22 @@ const isGettingInitialUser: Ref = ref(null) const persistenceService = getService(PersistenceService) const interceptorService = getService(KernelInterceptorService) +// Deferred: this module is evaluated before `initKernel`, so a top-level +// `getService(CookieJarService)` would permanently fail the jar's init and +// the cleanup below would see an empty jar. +const getCookieJarService = () => getService(CookieJarService) + +// Every backend call in this module authenticates with a bearer token +// from local config, so none of them should touch the shared request +// cookie jar. Without this the interceptor captures the backend's auth +// `Set-Cookie` into the jar and reattaches it to later calls, where a +// stale or blank `access_token` cookie is read in preference to the +// bearer token, the request is rejected, and desktop login stalls. +// Used as each request's `meta` so it opts out of jar attach and +// capture. A factory returns a fresh object per call, so a request that +// later needs other `meta.options` can add them inline without a shared +// top-level spread overwriting the whole `meta`. +const noCookieJarMeta = () => ({ options: { cookies: false } }) async function logout() { const { response } = interceptorService.execute({ @@ -53,6 +70,7 @@ async function logout() { url: `${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`, version: "HTTP/1.1", method: "GET", + meta: noCookieJarMeta(), }) await response @@ -117,6 +135,7 @@ async function getInitialUserDetails(): Promise< } }`, }), + meta: noCookieJarMeta(), }) const responseBytes = await response @@ -167,6 +186,50 @@ async function setUser(user: HoppUserWithAuthDetail | null) { ) } +// Older installs captured the backend's auth `Set-Cookie` into the shared +// jar before the opt-out above existed, so a blank or stale `access_token` +// or `refresh_token` cookie can still be persisted from a prior version and +// keep the login loop going. Remove any such entry for the backend host +// once on init. New installs never capture these, so this only heals state +// an earlier build left behind. +async function clearPersistedAuthCookiesFromJar() { + const cookieJarService = getCookieJarService() + await cookieJarService.whenReady() + + const authCookieNames = new Set(["access_token", "refresh_token"]) + const backendHosts = new Set() + for (const rawUrl of [ + import.meta.env.VITE_BACKEND_API_URL, + import.meta.env.VITE_BACKEND_GQL_URL, + ]) { + try { + backendHosts.add( + cookieJarService.canonStoreDomain(new URL(rawUrl).hostname) + ) + } catch { + // A malformed env URL leaves no host to clear. + } + } + + const targets: Array<{ domain: string; name: string; path: string }> = [] + for (const host of backendHosts) { + const bucket = cookieJarService.cookieJar.value.get(host) ?? [] + for (const cookie of bucket) { + if (authCookieNames.has(cookie.name)) { + targets.push({ + domain: cookie.domain, + name: cookie.name, + path: cookie.path, + }) + } + } + } + + if (targets.length > 0) { + await cookieJarService.deleteCookies(targets) + } +} + export async function setInitialUser() { isGettingInitialUser.value = true const res = await getInitialUserDetails() @@ -232,6 +295,7 @@ async function refreshToken() { headers: { Authorization: `Bearer ${refreshToken}`, }, + meta: noCookieJarMeta(), }) const res = await response @@ -269,6 +333,7 @@ async function sendMagicLink(email: string) { "Content-Type": "application/json", }, content: content.json({ email }), + meta: noCookieJarMeta(), }) const res = await response @@ -381,6 +446,7 @@ export const def: AuthPlatformDef = { const loginState = await persistenceService.getLocalConfig("login_state") const probableUser = JSON.parse(loginState ?? "null") probableUser$.next(probableUser) + await clearPersistedAuthCookiesFromJar() await setInitialUser() await listen( @@ -474,6 +540,7 @@ export const def: AuthPlatformDef = { token: verifyToken, deviceIdentifier, }), + meta: noCookieJarMeta(), }) const res = await response @@ -542,6 +609,7 @@ export const def: AuthPlatformDef = { "Content-Type": "application/json", ...this.getBackendHeaders(), }, + meta: noCookieJarMeta(), }) const res = await response diff --git a/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/types.go b/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/types.go index c7aef8a5328..17ffbbfe843 100644 --- a/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/types.go +++ b/packages/hoppscotch-selfhost-web/webapp-server/internal/bundle/types.go @@ -3,7 +3,7 @@ package bundle import "time" const ( - Version = "2026.6.0" + Version = "2026.6.1" DefaultMaxSize = 50 * 1024 * 1024 diff --git a/packages/hoppscotch-sh-admin/package.json b/packages/hoppscotch-sh-admin/package.json index b61d193c249..ae050728cd3 100644 --- a/packages/hoppscotch-sh-admin/package.json +++ b/packages/hoppscotch-sh-admin/package.json @@ -1,7 +1,7 @@ { "name": "hoppscotch-sh-admin", "private": true, - "version": "2026.6.0", + "version": "2026.6.1", "type": "module", "scripts": { "dev": "pnpm exec npm-run-all -p -l dev:*", diff --git a/packages/hoppscotch-sh-admin/prod_run.mjs b/packages/hoppscotch-sh-admin/prod_run.mjs index 12243f971ff..fc825d003a2 100755 --- a/packages/hoppscotch-sh-admin/prod_run.mjs +++ b/packages/hoppscotch-sh-admin/prod_run.mjs @@ -1,8 +1,52 @@ #!/usr/local/bin/node -import { execSync, spawn } from 'child_process'; +import { execFileSync, spawn } from 'child_process'; import fs from 'fs'; +import net from 'net'; +import os from 'os'; +import path from 'path'; import process from 'process'; +// Probe the real bind so the kernel decides (CAP_NET_BIND_SERVICE, port sysctls), +// not a UID guess. +async function assertPortBindable(port) { + await new Promise((resolve) => { + const probe = net.createServer(); + probe.once('error', (err) => { + if (err.code === 'EACCES') { + console.error(`Cannot bind port ${port} as the current user: set HOPP_ALTERNATE_PORT to a free port >= 1024, or run as root / grant CAP_NET_BIND_SERVICE.`); + process.exit(1); + } + if (err.code === 'EADDRINUSE') { + console.error(`Port ${port} is already in use inside the container: set HOPP_ALTERNATE_PORT to a free port.`); + process.exit(1); + } + console.warn(`Skipping bind preflight for port ${port} (${err.code})`); + resolve(); + }); + probe.listen(port, () => probe.close(resolve)); + }); +} + +// Empty means unset (compose passes undefined vars as ""), so the :80 default applies. +if (process.env.HOPP_ALTERNATE_PORT === '') delete process.env.HOPP_ALTERNATE_PORT; + +// Sanity-check the value; real bindability is probed below. +const RESERVED_PORTS = ['3100']; +const altPort = process.env.HOPP_ALTERNATE_PORT; +if (altPort !== undefined) { + if (!(/^[0-9]+$/.test(altPort) && +altPort >= 1 && +altPort <= 65535)) { + console.error(`HOPP_ALTERNATE_PORT="${altPort}" is invalid: use an integer in 1-65535 (e.g. 8000).`); + process.exit(1); + } + if (RESERVED_PORTS.includes(String(+altPort))) { + console.error(`HOPP_ALTERNATE_PORT="${altPort}" is already used by this image (${RESERVED_PORTS.join(', ')}); pick another port (e.g. 8000).`); + process.exit(1); + } +} + +// Caddy always binds this port (env value or the :80 default). +await assertPortBindable(+(process.env.HOPP_ALTERNATE_PORT ?? 80)); + function runChildProcessWithPrefix(command, args, prefix) { const childProcess = spawn(command, args); @@ -41,11 +85,17 @@ const envFileContent = Object.entries(process.env) ) .join('\n'); -fs.writeFileSync('build.env', envFileContent); +// Write to a temp dir (not cwd) so a non-root UID needn't own the working directory. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hopp-env-')); +const buildEnvPath = path.join(tmpDir, 'build.env'); -execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`); - -fs.rmSync('build.env'); +try { + fs.writeFileSync(buildEnvPath, envFileContent); + // Call the global binary directly (not npx, which needs a writable $HOME cache). + execFileSync('import-meta-env', ['-x', buildEnvPath, '-e', buildEnvPath, '-p', '/site/**/*'], { stdio: 'inherit' }); +} finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); +} const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' @@ -59,7 +109,8 @@ const caddyProcess = runChildProcessWithPrefix( caddyProcess.on('exit', (code) => { console.log(`Exiting process because Caddy Server exited with code ${code}`); - process.exit(code); + // code is null on signal death; report failure, not success. + process.exit(code ?? 1); }); process.on('SIGINT', () => { diff --git a/packages/hoppscotch-sh-admin/sh-admin-multiport-setup.Caddyfile b/packages/hoppscotch-sh-admin/sh-admin-multiport-setup.Caddyfile index cfcadc63102..9f61a60ff0b 100644 --- a/packages/hoppscotch-sh-admin/sh-admin-multiport-setup.Caddyfile +++ b/packages/hoppscotch-sh-admin/sh-admin-multiport-setup.Caddyfile @@ -3,7 +3,7 @@ persist_config off } -:80 :3100 { +:{$HOPP_ALTERNATE_PORT:80} :3100 { try_files {path} / root * /site/sh-admin-multiport-setup file_server diff --git a/packages/hoppscotch-sh-admin/sh-admin-subpath-access.Caddyfile b/packages/hoppscotch-sh-admin/sh-admin-subpath-access.Caddyfile index 5a6ad81a39e..c23ded5c1a6 100644 --- a/packages/hoppscotch-sh-admin/sh-admin-subpath-access.Caddyfile +++ b/packages/hoppscotch-sh-admin/sh-admin-subpath-access.Caddyfile @@ -3,7 +3,7 @@ persist_config off } -:80 :3100 { +:{$HOPP_ALTERNATE_PORT:80} :3100 { handle_path /admin* { root * /site/sh-admin-subpath-access file_server diff --git a/prod.Dockerfile b/prod.Dockerfile index a8dcfb2e1e4..b42b8b87fbd 100644 --- a/prod.Dockerfile +++ b/prod.Dockerfile @@ -155,6 +155,10 @@ COPY --from=base_builder /usr/src/app/packages/hoppscotch-backend/prod_run.mjs / ENV PRODUCTION="true" ENV PORT=8080 +# Writable Caddy storage for non-root UIDs (no writable $HOME needed). +ENV XDG_DATA_HOME=/tmp +ENV XDG_CONFIG_HOME=/tmp + WORKDIR /dist/backend CMD ["node", "prod_run.mjs"] @@ -166,6 +170,10 @@ EXPOSE 3170 FROM base_builder AS fe_builder WORKDIR /usr/src/app/packages/hoppscotch-selfhost-web RUN pnpm run generate +# Group-writable (GID 0) so a non-root UID (OpenShift runs as GID 0) can rewrite +# these files during env injection. Done here (not in the runtime stage) so the +# perms travel with the COPY instead of duplicating the layer with a chmod there. +RUN chmod -R g=rwX /usr/src/app/packages/hoppscotch-selfhost-web/dist @@ -178,11 +186,25 @@ COPY --from=webapp_server_builder /usr/src/app/packages/hoppscotch-selfhost-web/ COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/prod_run.mjs /site/prod_run.mjs COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/selfhost-web.Caddyfile /etc/caddy/selfhost-web.Caddyfile -COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist/ /site/selfhost-web +COPY --chown=root:0 --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist/ /site/selfhost-web + +# Writable Caddy storage for non-root UIDs (no writable $HOME needed). +ENV XDG_DATA_HOME=/tmp +ENV XDG_CONFIG_HOME=/tmp + +# Files keep g=rwX from the builder; only the COPY-created dirs need it. List the +# dirs (not /site/*) so the layer stays metadata-only and prod_run.mjs stays 755. +RUN chmod g=rwX /site /site/selfhost-web + +# Pre-create /data group-writable so webapp-server's signing key persists across +# restarts under a non-root UID (else it's regenerated and logged each start). +RUN mkdir -p /data/webapp-server && chmod g=rwX /data /data/webapp-server WORKDIR /site # Run both webapp-server and Caddy after env processing (NOTE: env processing is required by both) -CMD ["/bin/sh", "-c", "node /site/prod_run.mjs && (webapp-server & caddy run --config /etc/caddy/selfhost-web.Caddyfile --adapter caddyfile)"] +# An empty HOPP_ALTERNATE_PORT (compose passthrough of an undefined var) means unset, +# so the Caddyfile default (:80) applies. +CMD ["/bin/sh", "-c", "[ -n \"$HOPP_ALTERNATE_PORT\" ] || unset HOPP_ALTERNATE_PORT; node /site/prod_run.mjs && (webapp-server & caddy run --config /etc/caddy/selfhost-web.Caddyfile --adapter caddyfile)"] EXPOSE 80 EXPOSE 3000 @@ -195,6 +217,10 @@ WORKDIR /usr/src/app/packages/hoppscotch-sh-admin # Generate two builds for `sh-admin`, one based on subpath-access and the regular build RUN pnpm run build --outDir dist-multiport-setup RUN pnpm run build --outDir dist-subpath-access --base /admin/ +# Group-writable (GID 0) so a non-root UID (OpenShift runs as GID 0) can rewrite +# these files during env injection. Done here (not in the runtime stage) so the +# perms travel with the COPY instead of duplicating the layer with a chmod there. +RUN chmod -R g=rwX dist-multiport-setup dist-subpath-access FROM node_base AS sh_admin @@ -204,8 +230,16 @@ COPY --from=caddy_builder /tmp/caddy-build/cmd/caddy/caddy /usr/bin/caddy COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/prod_run.mjs /site/prod_run.mjs COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/sh-admin-multiport-setup.Caddyfile /etc/caddy/sh-admin-multiport-setup.Caddyfile COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/sh-admin-subpath-access.Caddyfile /etc/caddy/sh-admin-subpath-access.Caddyfile -COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-multiport-setup /site/sh-admin-multiport-setup -COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-subpath-access /site/sh-admin-subpath-access +COPY --chown=root:0 --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-multiport-setup /site/sh-admin-multiport-setup +COPY --chown=root:0 --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-subpath-access /site/sh-admin-subpath-access + +# Writable Caddy storage for non-root UIDs (no writable $HOME needed). +ENV XDG_DATA_HOME=/tmp +ENV XDG_CONFIG_HOME=/tmp + +# Files keep g=rwX from the builder; only the COPY-created dirs need it. List the +# dirs (not /site/*) so the layer stays metadata-only and prod_run.mjs stays 755. +RUN chmod g=rwX /site /site/sh-admin-multiport-setup /site/sh-admin-subpath-access WORKDIR /site CMD ["node","/site/prod_run.mjs"] @@ -236,17 +270,27 @@ COPY --from=base_builder /usr/src/app/packages/hoppscotch-backend/prod_run.mjs / # Static Server COPY --from=webapp_server_builder /usr/src/app/packages/hoppscotch-selfhost-web/webapp-server/webapp-server /usr/local/bin/ -RUN mkdir -p /site/selfhost-web -COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist /site/selfhost-web +COPY --chown=root:0 --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist /site/selfhost-web # FE Files COPY --from=base_builder /usr/src/app/aio_run.mjs /usr/src/app/aio_run.mjs -COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist /site/selfhost-web -COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-multiport-setup /site/sh-admin-multiport-setup -COPY --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-subpath-access /site/sh-admin-subpath-access +COPY --chown=root:0 --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-multiport-setup /site/sh-admin-multiport-setup +COPY --chown=root:0 --from=sh_admin_builder /usr/src/app/packages/hoppscotch-sh-admin/dist-subpath-access /site/sh-admin-subpath-access COPY aio-multiport-setup.Caddyfile /etc/caddy/aio-multiport-setup.Caddyfile COPY aio-subpath-access.Caddyfile /etc/caddy/aio-subpath-access.Caddyfile +# Writable Caddy storage for non-root UIDs (no writable $HOME needed). +ENV XDG_DATA_HOME=/tmp +ENV XDG_CONFIG_HOME=/tmp + +# Files keep g=rwX from the builders; only the COPY-created dirs need it. List the +# dirs (not /site/*) so the layer stays metadata-only. +RUN chmod g=rwX /site /site/selfhost-web /site/sh-admin-multiport-setup /site/sh-admin-subpath-access + +# Pre-create /data group-writable so webapp-server's signing key persists across +# restarts under a non-root UID (else it's regenerated and logged each start). +RUN mkdir -p /data/webapp-server && chmod g=rwX /data /data/webapp-server + ENTRYPOINT [ "tini", "--" ] COPY --chmod=755 healthcheck.sh / HEALTHCHECK --interval=2s --start-period=15s CMD /bin/sh /healthcheck.sh @@ -254,7 +298,10 @@ HEALTHCHECK --interval=2s --start-period=15s CMD /bin/sh /healthcheck.sh WORKDIR /dist/backend CMD ["node", "/usr/src/app/aio_run.mjs"] -# NOTE: Although these ports are exposed, the HOPP_ALTERNATE_AIO_PORT variable can be used to assign a user-specified port +# NOTE: In subpath mode (ENABLE_SUBPATH_BASED_ACCESS=true) HOPP_ALTERNATE_PORT sets +# Caddy's HTTP port (default 80). In multiport mode (default) Caddy uses the +# fixed ports 3000/3100/3170 and it has no effect. Legacy +# HOPP_AIO_ALTERNATE_PORT is still honoured. EXPOSE 3170 EXPOSE 3000 EXPOSE 3100