Self-hosting findings: URL-safe password advice, operator-readable pre-seed errors (#324, #325)
All checks were successful
All checks were successful
Two findings from Stefan's manual clean install per the guide, both ending in an api restart loop that was hard to diagnose: - #324: the guide recommended `openssl rand -base64 32` for POSTGRES_PASSWORD, but the compose interpolates the password unescaped into DATABASE_URL — base64's `/`, `+`, `=` break the URL. Misleadingly, db stays healthy (it gets the password as a plain env var) while api/collab/backup crash. Guide and .env.example now recommend `openssl rand -hex 24` for both secrets and say why; Troubleshooting gained the symptom line. - #325: SETUP_ADMIN_PASSWORD's minimum (10 chars, packages/shared/src/auth.ts) was undocumented, and a violation crashed the boot with a raw ZodError naming schema fields and i18n keys. Failing the boot stays — deliberately, no half-seeded instance — but preseedFromEnv now translates validation errors into operator terms ("Pre-seeding failed: SETUP_ADMIN_PASSWORD must be at least 10 characters. Fix .env and recreate the api container."). Documented in the guide's first-run section, .env.example, and Troubleshooting; new test pins the message and that nothing is half-seeded afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
This commit is contained in:
parent
d2be1116bc
commit
06b54747f1
@ -317,6 +317,42 @@ describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)'
|
|||||||
expect(locked.body.code).toBe('setup_locked');
|
expect(locked.body.code).toBe('setup_locked');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('env pre-seeding with invalid values (issue #325)', () => {
|
||||||
|
const dbName = `dorfteich_preseed_bad_${suffix}`;
|
||||||
|
let app: INestApplication;
|
||||||
|
const badEnv = {
|
||||||
|
SETUP_ADMIN_USERNAME: `preseed-bad-${suffix}`,
|
||||||
|
SETUP_ADMIN_EMAIL: `preseed-bad-${suffix}@example.org`,
|
||||||
|
SETUP_ADMIN_PASSWORD: 'short',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const url = await createFreshDatabase(dbName);
|
||||||
|
process.env.TEST_DATABASE_URL = url;
|
||||||
|
process.env.SECRETS_FILE = join(
|
||||||
|
mkdtempSync(join(tmpdir(), 'dorfteich-preseed-bad-')),
|
||||||
|
'secrets.env',
|
||||||
|
);
|
||||||
|
Object.assign(process.env, badEnv);
|
||||||
|
app = await createTestApp();
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const key of Object.keys(badEnv)) delete process.env[key];
|
||||||
|
await app.close();
|
||||||
|
await dropDatabase(dbName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the boot naming the SETUP_* variable, not a raw ZodError', async () => {
|
||||||
|
await expect(app.get(SetupService).preseedFromEnv()).rejects.toThrow(
|
||||||
|
/SETUP_ADMIN_PASSWORD must be at least 10 characters/,
|
||||||
|
);
|
||||||
|
// Fail-fast left nothing half-seeded: the wizard is still pending.
|
||||||
|
const status = await request(app.getHttpServer()).get('/api/v1/setup').expect(200);
|
||||||
|
expect(status.body.status).toBe('required');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
interface FakeSmtpServer {
|
interface FakeSmtpServer {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import { User } from '@prisma/client';
|
import { User } from '@prisma/client';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
import { SessionsService } from '../auth/sessions.service';
|
import { SessionsService } from '../auth/sessions.service';
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
@ -70,14 +71,23 @@ export class SetupService implements OnModuleInit {
|
|||||||
if (!(await this.state.isPending())) return;
|
if (!(await this.state.isPending())) return;
|
||||||
|
|
||||||
// Fails the boot loudly on invalid values — a half-seeded instance
|
// Fails the boot loudly on invalid values — a half-seeded instance
|
||||||
// would be much harder to diagnose than a startup error.
|
// would be much harder to diagnose than a startup error. Translated
|
||||||
const input = setupAdminInputSchema.parse({
|
// into operator terms first: the raw ZodError names schema fields and
|
||||||
|
// i18n keys, not the SETUP_* variable to fix (issue #325).
|
||||||
|
const parsed = setupAdminInputSchema.safeParse({
|
||||||
username: env.SETUP_ADMIN_USERNAME,
|
username: env.SETUP_ADMIN_USERNAME,
|
||||||
email: env.SETUP_ADMIN_EMAIL,
|
email: env.SETUP_ADMIN_EMAIL,
|
||||||
password: env.SETUP_ADMIN_PASSWORD,
|
password: env.SETUP_ADMIN_PASSWORD,
|
||||||
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
|
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
|
||||||
locale: env.SETUP_DEFAULT_LOCALE,
|
locale: env.SETUP_DEFAULT_LOCALE,
|
||||||
});
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(
|
||||||
|
`Pre-seeding failed: ${describePreseedIssues(parsed.error)}. ` +
|
||||||
|
'Fix .env and recreate the api container.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const input = parsed.data;
|
||||||
const admin = await this.createAdmin(input);
|
const admin = await this.createAdmin(input);
|
||||||
if (env.SETUP_INSTANCE_NAME) {
|
if (env.SETUP_INSTANCE_NAME) {
|
||||||
await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id);
|
await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id);
|
||||||
@ -223,3 +233,27 @@ export class SetupService implements OnModuleInit {
|
|||||||
return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0;
|
return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The env variable behind each schema field of the pre-seeded admin. */
|
||||||
|
const PRESEED_FIELD_TO_ENV: Record<string, string> = {
|
||||||
|
username: 'SETUP_ADMIN_USERNAME',
|
||||||
|
email: 'SETUP_ADMIN_EMAIL',
|
||||||
|
password: 'SETUP_ADMIN_PASSWORD',
|
||||||
|
displayName: 'SETUP_ADMIN_DISPLAY_NAME',
|
||||||
|
locale: 'SETUP_DEFAULT_LOCALE',
|
||||||
|
};
|
||||||
|
|
||||||
|
function describePreseedIssues(error: ZodError): string {
|
||||||
|
return error.issues
|
||||||
|
.map((issue) => {
|
||||||
|
const variable = PRESEED_FIELD_TO_ENV[String(issue.path[0])] ?? String(issue.path[0]);
|
||||||
|
if (issue.code === 'too_small' && issue.type === 'string') {
|
||||||
|
return `${variable} must be at least ${issue.minimum} characters`;
|
||||||
|
}
|
||||||
|
if (issue.code === 'invalid_string' && issue.validation === 'email') {
|
||||||
|
return `${variable} is not a valid e-mail address`;
|
||||||
|
}
|
||||||
|
return `${variable} is invalid (${issue.message})`;
|
||||||
|
})
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
|||||||
@ -2,14 +2,17 @@
|
|||||||
# next to docker-compose.yml and adjust the values.
|
# next to docker-compose.yml and adjust the values.
|
||||||
|
|
||||||
# --- required ---------------------------------------------------------------
|
# --- required ---------------------------------------------------------------
|
||||||
# PostgreSQL password for the `dorfteich` database user.
|
# PostgreSQL password for the `dorfteich` database user. URL-SAFE
|
||||||
|
# characters only (generate with `openssl rand -hex 24`): the compose file
|
||||||
|
# interpolates it into DATABASE_URL unescaped, so base64's `/`, `+`, `=`
|
||||||
|
# break the URL — db stays healthy while api/collab/backup restart-loop.
|
||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=change-me
|
||||||
|
|
||||||
# ROOT key of the token key hierarchy (ADR 0020, issue #188): every token
|
# ROOT key of the token key hierarchy (ADR 0020, issue #188): every token
|
||||||
# purpose (collaboration tokens, digest unsubscribe links) derives its own
|
# purpose (collaboration tokens, digest unsubscribe links) derives its own
|
||||||
# HKDF subkey from this value — nothing signs with it directly. The api and
|
# HKDF subkey from this value — nothing signs with it directly. The api and
|
||||||
# collab services share this one value; use a long random string
|
# collab services share this one value; use a long random string
|
||||||
# (e.g. `openssl rand -base64 32`). Min length 16. Rotating it rotates all
|
# (e.g. `openssl rand -hex 24`). Min length 16. Rotating it rotates all
|
||||||
# derived keys at once and invalidates outstanding tokens.
|
# derived keys at once and invalidates outstanding tokens.
|
||||||
COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string
|
COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
@ -155,6 +158,9 @@ SMTP_FROM=Dorfteich <wiki@example.com>
|
|||||||
# wizard. Automated deploys can skip it entirely by pre-seeding the Site
|
# wizard. Automated deploys can skip it entirely by pre-seeding the Site
|
||||||
# Admin here; the wizard then completes and locks itself at first boot.
|
# Admin here; the wizard then completes and locks itself at first boot.
|
||||||
# All three SETUP_ADMIN_* values are required for pre-seeding to trigger.
|
# All three SETUP_ADMIN_* values are required for pre-seeding to trigger.
|
||||||
|
# The wizard's validation applies: the password needs at least 10
|
||||||
|
# characters — a violation fails the boot with a message naming the
|
||||||
|
# variable (deliberate: no half-seeded instance).
|
||||||
#SETUP_ADMIN_USERNAME=admin
|
#SETUP_ADMIN_USERNAME=admin
|
||||||
#SETUP_ADMIN_EMAIL=admin@example.com
|
#SETUP_ADMIN_EMAIL=admin@example.com
|
||||||
#SETUP_ADMIN_PASSWORD=change-me-please
|
#SETUP_ADMIN_PASSWORD=change-me-please
|
||||||
|
|||||||
@ -33,8 +33,12 @@ work, that is a bug (issue #88).
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. Edit `.env` — the minimum:
|
2. Edit `.env` — the minimum:
|
||||||
- `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings
|
- `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings —
|
||||||
(`openssl rand -base64 32`).
|
generate both with `openssl rand -hex 24`. Stick to URL-safe
|
||||||
|
characters for the database password (hex is): it is interpolated
|
||||||
|
into a connection URL, and a `/`, `+` or `=` from base64 output
|
||||||
|
breaks it in a confusing way (db healthy, everything else
|
||||||
|
restart-looping — see Troubleshooting).
|
||||||
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`: pin
|
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`: pin
|
||||||
the latest release tag (semver, e.g. `v0.14.0`) — the
|
the latest release tag (semver, e.g. `v0.14.0`) — the
|
||||||
[release list](https://gitea.101010.cloud/stwaidele/dorfteich/releases)
|
[release list](https://gitea.101010.cloud/stwaidele/dorfteich/releases)
|
||||||
@ -81,7 +85,10 @@ and health endpoints with `503 setup_required` — that is not an error.
|
|||||||
|
|
||||||
Unattended installs skip the wizard by pre-seeding: set the
|
Unattended installs skip the wizard by pre-seeding: set the
|
||||||
`SETUP_ADMIN_*` variables in `.env` before the first start (see
|
`SETUP_ADMIN_*` variables in `.env` before the first start (see
|
||||||
`.env.example`).
|
`.env.example`). The same validation as in the wizard applies —
|
||||||
|
`SETUP_ADMIN_PASSWORD` needs **at least 10 characters** — and an invalid
|
||||||
|
value deliberately fails the boot with a message naming the variable
|
||||||
|
(a half-seeded instance would be harder to diagnose).
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
@ -185,6 +192,15 @@ and the OpenAPI document: [public-api.md](public-api.md).
|
|||||||
mutations fail with 403 `csrf_origin_mismatch` → `APP_BASE_URL` does not
|
mutations fail with 403 `csrf_origin_mismatch` → `APP_BASE_URL` does not
|
||||||
match the URL in the browser (scheme and host must be identical).
|
match the URL in the browser (scheme and host must be identical).
|
||||||
E-mail links point at the wrong host → same variable.
|
E-mail links point at the wrong host → same variable.
|
||||||
|
- api, collab **and** backup restart-looping while `db` is healthy →
|
||||||
|
`POSTGRES_PASSWORD` contains characters that break the connection URL
|
||||||
|
(base64's `/`, `+`, `=`); regenerate with `openssl rand -hex 24` and
|
||||||
|
recreate the stack. The db container looks fine because only its
|
||||||
|
clients build a URL from the password.
|
||||||
|
- api restart-looping right after the first start with a
|
||||||
|
`Pre-seeding failed` (or `validation.password.tooShort`) message →
|
||||||
|
`SETUP_ADMIN_PASSWORD` is shorter than 10 characters; fix `.env` and
|
||||||
|
recreate the api container.
|
||||||
- Wizard reappears after a restart → the database volume was not
|
- Wizard reappears after a restart → the database volume was not
|
||||||
persisted; never run without the `db-data` volume.
|
persisted; never run without the `db-data` volume.
|
||||||
- `docker compose ps` shows `unhealthy` → that container's liveness check
|
- `docker compose ps` shows `unhealthy` → that container's liveness check
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user