Merge pull request #117 from LINUXexpert-org/immutable-session-seam

Let the container run with nothing writable
This commit is contained in:
LINUXexpert.org
2026-08-27 21:52:02 -07:00
committed by GitHub
7 changed files with 200 additions and 14 deletions
+12
View File
@@ -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
View File
@@ -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"]
+23
View File
@@ -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
View File
@@ -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
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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;