Merge pull request #117 from LINUXexpert-org/immutable-session-seam
Let the container run with nothing writable
This commit is contained in:
@@ -28,8 +28,20 @@ SESSION_TTL=43200
|
||||
SESSION_REMEMBER_TTL=2592000
|
||||
|
||||
# Where to persist sessions so restarts don't log everyone out (optional).
|
||||
# Leave it empty to hold sessions in memory only, which is what an immutable
|
||||
# instance does -- see IMMUTABLE below.
|
||||
SESSION_FILE=./data/sessions.json
|
||||
|
||||
# Assert that this instance is running as an immutable container: read-only
|
||||
# root filesystem, no durable state of its own. It is checked rather than
|
||||
# taken on trust -- the server refuses to start if SESSION_FILE is set, or if
|
||||
# the filesystem it is installed on turns out to be writable. Off by default.
|
||||
# Running one looks like:
|
||||
# docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
|
||||
# The cost today is that a restart signs everyone out, since there is nowhere
|
||||
# left to keep the sessions. Removing that cost is what the OAuth work is for.
|
||||
# IMMUTABLE=1
|
||||
|
||||
# Upstream timeouts / limits
|
||||
UPSTREAM_TIMEOUT=30000
|
||||
MAX_UPLOAD_BYTES=52428800
|
||||
|
||||
+9
-1
@@ -37,7 +37,15 @@ COPY --from=build /app/server/dist ./server/dist
|
||||
COPY --from=build /app/web/dist ./web/dist
|
||||
RUN mkdir -p /data && chown -R node:node /data /app
|
||||
USER node
|
||||
VOLUME ["/data"]
|
||||
# No `VOLUME ["/data"]`. It reads like documentation for where the session file
|
||||
# goes, but Docker acts on it: a container started without `-v` gets an
|
||||
# anonymous volume mounted there anyway, and that mount stays writable even
|
||||
# under `--read-only`. So the directive quietly put a writable hole in a
|
||||
# container meant to be immutable, and left an orphaned volume behind every
|
||||
# time one was replaced -- while never persisting anything across a redeploy,
|
||||
# since each new container got a fresh empty volume of its own. Deployments
|
||||
# that want the sessions to survive say so themselves: docker-compose.yml and
|
||||
# deploy.example.sh both mount a *named* volume at /data, which is unaffected.
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1
|
||||
CMD ["node", "server/dist/index.js"]
|
||||
|
||||
@@ -83,6 +83,29 @@ Full instructions, TLS, and every environment variable:
|
||||
[Installing](https://docs.ihasmail.org/install/) ·
|
||||
[Configuring](https://docs.ihasmail.org/configure/).
|
||||
|
||||
### Running immutably
|
||||
|
||||
The server writes to exactly one path, the optional `SESSION_FILE`. Clear it
|
||||
and there is nothing left to write, so the container can run with no writable
|
||||
filesystem at all:
|
||||
|
||||
```bash
|
||||
docker run --read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= ...
|
||||
```
|
||||
|
||||
`IMMUTABLE=1` is an assertion the server checks at startup rather than a switch
|
||||
that changes what it does: it refuses to start if `SESSION_FILE` is still set,
|
||||
or if the filesystem it is installed on turns out to be writable after all.
|
||||
Without it the same misconfiguration is silent — sessions are held in memory
|
||||
and persisting them is best-effort, so a read-only `/data` costs one warning at
|
||||
the first sign-in and nothing else until the instance is replaced and everyone
|
||||
is signed out.
|
||||
|
||||
That sign-out is the standing cost of this mode today, since sessions have
|
||||
nowhere to live across a restart. Removing it means moving the session upstream
|
||||
into a token Stalwart itself issues and can revoke, which is what the OAuth work
|
||||
in [ROADMAP.md](ROADMAP.md) is for.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import type { Context, MiddlewareHandler } from "hono";
|
||||
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { config } from "./config.js";
|
||||
import { SessionStore, type LiveSession } from "./sessions.js";
|
||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { resolveClientIp } from "./clientip.js";
|
||||
import {
|
||||
@@ -34,7 +34,7 @@ import { staticHandler } from "./static.js";
|
||||
|
||||
type Env = { Variables: { session: LiveSession } };
|
||||
|
||||
export const sessions = new SessionStore(config.sessionFile);
|
||||
export const sessions: SessionBackend = new SessionStore(config.sessionFile);
|
||||
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000);
|
||||
/**
|
||||
* Credential changes verify the current password upstream, and Stalwart's
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { assertImmutable } from "./config.js";
|
||||
|
||||
function tempRoot(): string {
|
||||
return mkdtempSync(join(tmpdir(), "ihasmail-immutable-"));
|
||||
}
|
||||
|
||||
test("IMMUTABLE refuses a configured SESSION_FILE", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
assert.throws(() => assertImmutable("/data/sessions.json", root), /SESSION_FILE is \/data\/sessions\.json/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("IMMUTABLE refuses a writable root, and leaves no probe behind", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
assert.throws(() => assertImmutable("", root), /is writable/);
|
||||
assert.equal(existsSync(join(root, ".immutable-probe")), false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("IMMUTABLE accepts a root it cannot write to", () => {
|
||||
const root = tempRoot();
|
||||
try {
|
||||
chmodSync(root, 0o555);
|
||||
assert.doesNotThrow(() => assertImmutable("", root));
|
||||
} finally {
|
||||
chmodSync(root, 0o755);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+56
-2
@@ -1,7 +1,7 @@
|
||||
import { resolveVersion } from "../../scripts/version.mjs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/** Minimal .env loader (no dependency): first match wins, never overrides real env. */
|
||||
@@ -58,6 +58,58 @@ if (!appSecret || appSecret === "change-me") {
|
||||
|
||||
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, "");
|
||||
|
||||
/**
|
||||
* Declares that this instance is running as an immutable container: read-only
|
||||
* root filesystem, nothing durable of its own, replaceable by its image.
|
||||
*
|
||||
* It is a claim the process checks rather than one it takes on trust, because
|
||||
* the failure it guards against is silent. Left to itself the server survives
|
||||
* a read-only filesystem perfectly well -- sessions are held in memory and the
|
||||
* write is best-effort, so the only sign that `SESSION_FILE` is going nowhere
|
||||
* is one warning at the first login, long after anyone was watching. The
|
||||
* instance looks healthy right up until it is replaced and everyone is signed
|
||||
* out. Setting IMMUTABLE turns both halves of that into a refusal to start.
|
||||
*/
|
||||
const immutable = bool("IMMUTABLE", false);
|
||||
const sessionFile = process.env.SESSION_FILE ?? "";
|
||||
|
||||
/**
|
||||
* Refuse to run when the promise IMMUTABLE makes is not one this instance can
|
||||
* keep. Exported so it can be tested without a read-only filesystem to hand.
|
||||
*/
|
||||
export function assertImmutable(sessionFile: string, root: string): void {
|
||||
// The image sets SESSION_FILE=/data/sessions.json, so this is a deliberate
|
||||
// refusal rather than a formality: running immutably means clearing it. It
|
||||
// is not quietly ignored, because a configured path that silently persists
|
||||
// nothing is exactly the failure this flag exists to surface.
|
||||
if (sessionFile) {
|
||||
throw new Error(
|
||||
`IMMUTABLE is set, but SESSION_FILE is ${sessionFile}. An immutable instance keeps no durable state of its own: ` +
|
||||
"pass SESSION_FILE= (empty) to hold sessions in memory, or unset IMMUTABLE.",
|
||||
);
|
||||
}
|
||||
// And check the property itself, not just the intention to have it. Setting
|
||||
// the variable while forgetting `--read-only` is the easy mistake, and it
|
||||
// leaves an instance claiming a guarantee it does not have.
|
||||
const probe = resolve(root, ".immutable-probe");
|
||||
let writable = false;
|
||||
try {
|
||||
writeFileSync(probe, "");
|
||||
writable = true;
|
||||
unlinkSync(probe);
|
||||
} catch {
|
||||
/* EROFS, or EACCES on a root we do not own: either way, not writable by us */
|
||||
}
|
||||
if (writable) {
|
||||
throw new Error(
|
||||
`IMMUTABLE is set, but ${root} is writable. Run the container with --read-only (and --tmpfs /tmp), or unset IMMUTABLE.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (immutable) assertImmutable(sessionFile, fileURLToPath(new URL("../..", import.meta.url)));
|
||||
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "ihasmail"),
|
||||
@@ -92,7 +144,9 @@ export const config = {
|
||||
secureCookies: (process.env.SECURE_COOKIES ?? "auto").toLowerCase(),
|
||||
sessionTtl: int("SESSION_TTL", 12 * 60 * 60),
|
||||
sessionRememberTtl: int("SESSION_REMEMBER_TTL", 30 * 24 * 60 * 60),
|
||||
sessionFile: process.env.SESSION_FILE ?? "",
|
||||
sessionFile,
|
||||
/** True when this instance has asserted, and verified, that it is immutable. */
|
||||
immutable,
|
||||
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
|
||||
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
|
||||
imageProxy: bool("IMAGE_PROXY", true),
|
||||
|
||||
+58
-9
@@ -34,9 +34,64 @@ export interface LiveSession {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/** What `/api/auth/sessions` reports about a session, with nothing secret in it. */
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionParams {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the rest of the server asks of a session store.
|
||||
*
|
||||
* There is one implementation today -- `SessionStore` below, which keeps the
|
||||
* records in memory and optionally mirrors them to `SESSION_FILE`. The reason
|
||||
* it is named as an interface anyway is that a second one is planned: a
|
||||
* stateless backend that carries the whole record in the cookie, so that a
|
||||
* replica can serve a session it never issued and `/data` can go away. Callers
|
||||
* written against the concrete class would all have to be revisited then.
|
||||
*
|
||||
* Five of these are already stateless in shape -- `create`, `resolve`,
|
||||
* `reseal` and `destroy` each touch exactly one session, and the sealing key is
|
||||
* derived from the cookie secret (see `crypto.ts`), so the record can move into
|
||||
* the cookie without the server keeping a map.
|
||||
*
|
||||
* The other two cannot be. `listForUser` and `destroyAllForUser` have to reach
|
||||
* sessions other than the one presenting itself, which means something has to
|
||||
* be enumerable somewhere. `destroyAllForUser` is not only the "sign out my
|
||||
* other sessions" button: `app.ts` also calls it when the password or the app
|
||||
* password changes, so it carries the guarantee that changing a credential
|
||||
* invalidates the sessions still holding the old one. A stateless backend
|
||||
* cannot honour that alone; the plan is for OAuth to hand the job to
|
||||
* Stalwart's own token registry, which can already answer both questions.
|
||||
*/
|
||||
export interface SessionBackend {
|
||||
init(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession };
|
||||
resolve(cookie: string | undefined): LiveSession | null;
|
||||
reseal(cookie: string | undefined, password: string): boolean;
|
||||
destroy(id: string): void;
|
||||
destroyAllForUser(username: string, exceptId?: string): number;
|
||||
listForUser(username: string): SessionSummary[];
|
||||
}
|
||||
|
||||
const COOKIE_SEP = ".";
|
||||
|
||||
export class SessionStore {
|
||||
export class SessionStore implements SessionBackend {
|
||||
private sessions = new Map<string, StoredSession>();
|
||||
private dirty = false;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
@@ -104,13 +159,7 @@ export class SessionStore {
|
||||
}
|
||||
|
||||
/** Create a session; returns the cookie value to hand to the client. */
|
||||
create(params: {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
}): { cookie: string; session: LiveSession } {
|
||||
create(params: CreateSessionParams): { cookie: string; session: LiveSession } {
|
||||
const id = randomToken(18);
|
||||
const secret = randomToken(32);
|
||||
const salt = randomBytes(16);
|
||||
@@ -211,7 +260,7 @@ export class SessionStore {
|
||||
return n;
|
||||
}
|
||||
|
||||
listForUser(username: string): Array<Omit<StoredSession, "secretHash" | "salt" | "sealedCredentials">> {
|
||||
listForUser(username: string): SessionSummary[] {
|
||||
const out = [];
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.username !== username) continue;
|
||||
|
||||
Reference in New Issue
Block a user