diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be2084..d582597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ Versions before 1.0 may include breaking changes in minor releases. Patch releases contain compatible fixes. Release candidates are intended for evaluation with synthetic data or a separate representative copy. +## [0.1.7] + +### Fixed + +- Setup requests now time out after 60 seconds per HTTP exchange with guidance to check the installation state before retrying. Long-running import and takeover jobs continue on the server. +- An app process waiting for setup detects when another process has initialized its database, blocks application access and requests a restart instead of offering setup again. Concurrent local setup requests are rejected while a setup operation is active. +- Setup jobs log their start, progress and completion with a shared diagnostic reference without exposing uploaded data, credentials or job tokens. +- TOTP sign-in explains the active method and presents recovery codes as a smaller emergency option below the main actions. Switching methods clears the input and focuses the code field; the new text is translated into all 14 supported languages. +- Add spacing between the sysPass XML import hint and the preview button. + +### Upgrade notes + +- No database schema change is introduced. Preserve existing deployment keys, secrets, persistent volumes and backups, and run a single application process. +- If setup reports `SETUP_STATE_CHANGED`, stop duplicate app instances and restart the intended application with the existing database, data volume and secrets. A browser timeout does not cancel a server-side setup job. +- The cumulative security catalogue retains NP-2026-001 and NP-2026-002; this release adds no newly disclosed security corrections. + ## [0.1.6] ### Fixed diff --git a/README.md b/README.md index 72eb52d..d292403 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ All browser assets are served locally. There are no CDNs, remote fonts, external ## Status and security responsibilities -nodePass is version **0.1.6**. Implemented features and historical local testing do not constitute a production approval. Before deployment, require **Required release checks** (`release-gate`) to pass on the exact commit, test migration and restore with a representative copy of your own data, and validate your real integrations and proxy configuration. Automated scanners are not an independent security audit. No independent external security audit is claimed. +nodePass is version **0.1.7**. Implemented features and historical local testing do not constitute a production approval. Before deployment, require **Required release checks** (`release-gate`) to pass on the exact commit, test migration and restore with a representative copy of your own data, and validate your real integrations and proxy configuration. Automated scanners are not an independent security audit. No independent external security audit is claimed. nodePass is **not end-to-end encrypted**. The running server can decrypt secrets for authorized operations. The application host, deployment key and operators with infrastructure access are part of the trust boundary. Keep keys separate from backups and protect the host accordingly. diff --git a/SECURITY.md b/SECURITY.md index fb11d10..fc83d9f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,6 +10,6 @@ Reports concern nodePass. The upstream sysPass project's contact address is not ## Version and verification policy -The current project version is 0.1.6. Report against the exact commit when possible. No long-term support branches or fixed support periods are promised. Production deployment requires the exact commit's **Required release checks** and operator-specific validation; local reports alone are insufficient. +The current project version is 0.1.7. Report against the exact commit when possible. No long-term support branches or fixed support periods are promised. Production deployment requires the exact commit's **Required release checks** and operator-specific validation; local reports alone are insufficient. Read the [security model](docs/SECURITY.md) for trust boundaries and known constraints, and [Operations](docs/OPERATIONS.md#release-and-hosting-checks) for release requirements. Automated scans do not constitute an independent security audit. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 751b630..23cc3f9 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -123,6 +123,16 @@ Mount inputs read-only and make them readable only by app UID 65532. Keep sensit - Follow [Updates and upgrades](UPGRADING.md) for the maintenance procedure, including Coolify/Dokploy image selection, backup/restore, startup migrations and post-update checks. - After an incomplete takeover, preserve the partial DB and restore the original backup separately before pointing sysPass at it. See [rollback](MIGRATION.md#verification-and-rollback). +### Setup stops responding or reappears after import + +Setup is selected by the server's `/api/v1/status` response, not by browser display preferences or saved passkeys. An old session cookie can require another sign-in, and a passkey from a deleted installation needs registration in the new installation; neither changes the database's initialization flag. + +The browser bounds each setup HTTP exchange to 60 seconds. Import and takeover jobs can run longer while their status requests continue to respond. A response timeout ends the browser's wait only: the server job may still be active. Reload to check the installation state before submitting again, and preserve the database, data volume and deployment secrets. If legacy data has already been imported, continue takeover without uploading the dump again. + +Compare `/api/v1/status`, `/health/live` and `/health/ready` on the same public origin. If status reports `setupRequired: true` after a completed setup, verify that the domain routes to the intended single app process and database, and check container restarts, volume mappings and the deployment key. If even liveness does not respond, inspect the app container and ingress before retrying imports. Setup logs contain `setup.started`, `setup.progress`, `setup.completed` and `setup.failed` events correlated by a reference, without uploaded data, credentials or job tokens. Keep the last phase and reference when reporting a failure. + +An app process that was waiting for setup rechecks the database before reporting status or accepting setup requests. If another process initialized the database meanwhile, it returns HTTP 503 with `SETUP_STATE_CHANGED`, logs `setup.state.changed` and blocks application access instead of offering setup again. Liveness stays available and readiness reports `restart-required`. Stop duplicate app instances and restart the intended single app with the same database, volume and secrets. This guard does not add support for active replicas. + ## Release and hosting checks Follow the [release procedure](RELEASES.md) for version preparation, repository rules, approval, immutable assets and digest-pinned deployment. Release candidates never advance the stable `latest` image tag. diff --git a/package-lock.json b/package-lock.json index 394d7e7..7197fd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nodepass", - "version": "0.1.6", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodepass", - "version": "0.1.6", + "version": "0.1.7", "license": "GPL-3.0-or-later", "workspaces": [ "packages/*" @@ -7081,21 +7081,21 @@ }, "packages/contracts": { "name": "@nodepass/contracts", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@feathersjs/typebox": "^5.0.35" } }, "packages/server": { "name": "@nodepass/server", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@feathersjs/errors": "^5.0.35", "@feathersjs/feathers": "^5.0.35", "@feathersjs/koa": "^5.0.35", "@feathersjs/schema": "^5.0.35", "@koa/router": "^15.7.0", - "@nodepass/contracts": "0.1.6", + "@nodepass/contracts": "0.1.7", "@simplewebauthn/server": "^13.2.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", @@ -7117,9 +7117,9 @@ }, "packages/web": { "name": "@nodepass/web", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { - "@nodepass/contracts": "0.1.6", + "@nodepass/contracts": "0.1.7", "@simplewebauthn/browser": "^13.2.0", "@vitejs/plugin-vue": "^6.0.0", "@zxcvbn-ts/core": "^3.0.4", diff --git a/package.json b/package.json index 62fe18c..a24e0b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodepass", - "version": "0.1.6", + "version": "0.1.7", "private": true, "description": "Self-hosted sysPass successor with a Feathers 5 API and Vue 3 UI", "license": "GPL-3.0-or-later", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index e6db763..0f66ac9 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@nodepass/contracts", - "version": "0.1.6", + "version": "0.1.7", "private": true, "type": "module", "exports": { diff --git a/packages/contracts/src/version.ts b/packages/contracts/src/version.ts index 3c2dfff..806b827 100644 --- a/packages/contracts/src/version.ts +++ b/packages/contracts/src/version.ts @@ -1 +1 @@ -export const version = "0.1.6"; +export const version = "0.1.7"; diff --git a/packages/server/package.json b/packages/server/package.json index af02a7d..b919084 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@nodepass/server", - "version": "0.1.6", + "version": "0.1.7", "private": true, "type": "module", "scripts": { @@ -17,7 +17,7 @@ "@feathersjs/koa": "^5.0.35", "@feathersjs/schema": "^5.0.35", "@koa/router": "^15.7.0", - "@nodepass/contracts": "0.1.6", + "@nodepass/contracts": "0.1.7", "@simplewebauthn/server": "^13.2.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index f3b4e71..16356f4 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -127,6 +127,13 @@ export async function createApp( let status = maintenance ? { installed: false, legacy: false, ready: false } : await migration.status(); + let setupStateChanged = false; + let freshSetupActive = false; + const changedSetupState = () => + new Unavailable( + "The database was initialized after this app process started. Restart this app and verify that only one app instance uses this database. Keep the database, data volume and deployment secrets.", + { code: "SETUP_STATE_CHANGED" }, + ); let updates = new Updates( db, integrations, @@ -251,6 +258,13 @@ export async function createApp( throw new Forbidden("Untrusted proxy headers"); if (config.production && !ctx.secure && !ctx.path.startsWith("/health/")) throw new Forbidden("HTTPS is required"); + if ( + ctx.path === "/api/v1/status" || + ctx.path.startsWith("/api/v1/setup/") + ) + await reconcileSetupState(); + if (setupStateChanged && application && !restoreStatus) + throw changedSetupState(); if ( ctx.path.startsWith("/api/v1/setup/") && !["GET", "HEAD"].includes(ctx.method) @@ -334,10 +348,16 @@ export async function createApp( ].includes(error.data?.code) ? { code: error.data.code } : {}), - ...(error instanceof Unavailable && error.data?.code === "MAINTENANCE" - ? { code: "MAINTENANCE" } + ...(error instanceof Unavailable && + ["MAINTENANCE", "SETUP_STATE_CHANGED"].includes(error.data?.code) + ? { code: error.data.code } : {}), }; + if ( + error instanceof Unavailable && + error.data?.code === "SETUP_STATE_CHANGED" + ) + ctx.body.message = error.message; if (ctx.status >= 500) console.error( JSON.stringify({ @@ -422,14 +442,39 @@ export async function createApp( }; const setupJobs = new Map(), setupTasks = new Set>(); + const canReconcileSetup = () => + !maintenance && + !status.ready && + !setupStateChanged && + !freshSetupActive && + !setupTasks.size; + const reconcileSetupState = async () => { + if (!canReconcileSetup()) return; + const current = await migration.status(); + // A concurrent local setup can complete while the status query is in flight. + if (!canReconcileSetup()) return; + if (current.ready) { + setupStateChanged = true; + console.error(JSON.stringify({ event: "setup.state.changed" })); + } else status = current; + }; const queueSetup = (kind: string, work: () => Promise): SetupJob => { - if ([...setupJobs.values()].some((job) => job.state === "running")) + if ( + freshSetupActive || + [...setupJobs.values()].some((job) => job.state === "running") + ) throw new Conflict("A setup job is already active"); if (setupJobs.size >= 5) setupJobs.delete(setupJobs.keys().next().value!); const job: SetupJob = { id: token(), kind, step: kind, state: "running" }; + const reference = randomUUID(); + const logProgress = (event: string) => + console.info(JSON.stringify({ event, reference, kind, step: job.step })); setupJobs.set(job.id, job); + logProgress("setup.started"); migration.onProgress = (step) => { + if (step === job.step) return; job.step = step; + logProgress("setup.progress"); }; const task = (async () => { try { @@ -438,11 +483,12 @@ export async function createApp( await startJobs(); job.state = "complete"; job.step = "saved"; + logProgress("setup.completed"); } catch (error) { job.state = "failed"; job.error = { ...setupFailure(error), - reference: randomUUID(), + reference, step: job.step, }; console.error( @@ -486,13 +532,18 @@ export async function createApp( ctx.body = { status: "database-unavailable" }; return; } - ctx.status = !maintenance && status.ready && vault.unlocked ? 200 : 503; + ctx.status = + !maintenance && !setupStateChanged && status.ready && vault.unlocked + ? 200 + : 503; ctx.body = { status: maintenance ? "maintenance" - : ctx.status === 200 - ? "ready" - : "setup-required", + : setupStateChanged + ? "restart-required" + : ctx.status === 200 + ? "ready" + : "setup-required", }; }); router.get("/api/v1/status", async (ctx) => { @@ -528,15 +579,22 @@ export async function createApp( await setup(ctx); const data = body(ctx); exact(data, ["login", "name", "password"]); - await migration.fresh({ - login: text(data, "login", 50), - name: text(data, "name", 80), - password: text(data, "password"), - token: ctx.get("x-setup-token"), - }); - status = await migration.status(); - await startJobs(); - ctx.body = { ready: status.ready }; + if (freshSetupActive || setupTasks.size) + throw new Conflict("A setup job is already active"); + freshSetupActive = true; + try { + await migration.fresh({ + login: text(data, "login", 50), + name: text(data, "name", 80), + password: text(data, "password"), + token: ctx.get("x-setup-token"), + }); + status = await migration.status(); + await startJobs(); + ctx.body = { ready: status.ready }; + } finally { + freshSetupActive = false; + } }); router.post("/api/v1/setup/migrate", async (ctx) => { await setup(ctx); diff --git a/packages/web/package.json b/packages/web/package.json index eba560f..27fc140 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@nodepass/web", - "version": "0.1.6", + "version": "0.1.7", "private": true, "type": "module", "scripts": { @@ -10,7 +10,7 @@ "rtk:build": "rtk npm run build" }, "dependencies": { - "@nodepass/contracts": "0.1.6", + "@nodepass/contracts": "0.1.7", "@simplewebauthn/browser": "^13.2.0", "@vitejs/plugin-vue": "^6.0.0", "@zxcvbn-ts/core": "^3.0.4", diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index 3ccd632..0aced0e 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -28,6 +28,7 @@ import { cancelConfirmation } from "./confirmation"; import { uiLocales, dateLocales } from "./ui-locale"; import type { Language, UpdateStatus } from "@nodepass/contracts"; import { api } from "./api"; +import { setupErrorMessage } from "./setup-errors"; import { canManage, managementNavigation } from "./navigation"; export default defineComponent({ components: { @@ -61,6 +62,16 @@ export default defineComponent({ poller: undefined as ReturnType | undefined, }), computed: { + setupRestartMessage() { + return setupErrorMessage( + { + code: "SETUP_STATE_CHANGED", + message: + "The database was initialized after this app process started. Restart this app and verify that only one app instance uses this database. Keep the database, data volume and deployment secrets.", + }, + String(this.$i18n.locale), + ); + }, componentLocale() { return uiLocales[this.$i18n.locale as Language]; }, @@ -172,6 +183,7 @@ export default defineComponent({ window.addEventListener("nodepass-expired", this.expired); window.addEventListener("nodepass-refresh", this.refreshSession); window.addEventListener("nodepass-maintenance", this.enterMaintenance); + window.addEventListener("nodepass-restart-required", this.requireRestart); window.addEventListener("focus", this.activated); document.addEventListener("visibilitychange", this.activated); await this.sessionStore.load(); @@ -187,10 +199,18 @@ export default defineComponent({ window.removeEventListener("nodepass-expired", this.expired); window.removeEventListener("nodepass-refresh", this.refreshSession); window.removeEventListener("nodepass-maintenance", this.enterMaintenance); + window.removeEventListener( + "nodepass-restart-required", + this.requireRestart, + ); window.removeEventListener("focus", this.activated); document.removeEventListener("visibilitychange", this.activated); }, methods: { + requireRestart() { + this.sessionStore.restartRequired = true; + this.sessionStore.clear(); + }, enterMaintenance() { this.sessionStore.maintenance = true; cancelConfirmation(); @@ -291,7 +311,7 @@ export default defineComponent({ class="app-root" :class="{ 'is-dark': sessionStore.theme === 'dark' }" > - + - {{ - sessionStore.error - }} +
+ {{ + sessionStore.restartRequired + ? setupRestartMessage + : sessionStore.error + }}
- + + +
+ {{ + $t(recoveryMode ? "useAuthenticatorCode" : "useRecoveryCode") + }} +