Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Never bake local state or secrets into the image
data/
.env*
node_modules/

# VCS, CI and tooling metadata
.git/
.github/
.claude/
.vscode/
.idea/

# Docs, tests and dev-only scripts (not needed at runtime)
*.md
LICENSE
test-qa.js
test-ui-save.js
capture-screenshots.js
scenario_template.md
Clinical-Simulation-Scenario-Master-Template.docx
public/screenshots/

# Docker artefacts themselves
Dockerfile
.dockerignore
docker-compose.yml

# OS noise
.DS_Store
Thumbs.db
72 changes: 72 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Builds the SimHub container and publishes it to GitHub Container Registry
# (ghcr.io) on every push to main, and with a version tag when a release tag
# (v*) is pushed. Pull requests build the image (a smoke check) but do not
# publish it.
#
# No secrets to configure: GITHUB_TOKEN is provided automatically.

name: Publish Docker image

on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]

env:
REGISTRY: ghcr.io
# ghcr requires a lowercase image name
IMAGE_NAME: ghcr.io/authortom/simhub

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout
uses: actions/checkout@v4

# Emulation + buildx enable multi-architecture images, so the same tag
# runs on ordinary x86 servers and ARM boxes (Apple Silicon, Raspberry Pi).
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# latest -> every push to main
# v1.2.3 / 1.2 -> release tags
# sha-<commit> -> immutable reference for rollbacks
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to SimHub are documented in this file, newest first.

---

## [2026-07-06] - Docker Distribution

* **Official Container Image**: New `Dockerfile` producing a small (~60 MB) production image on `node:20-alpine` — production dependencies only, runs as the unprivileged `node` user, persistent data isolated in a `/app/data` volume, built-in Docker `HEALTHCHECK`, and `node server.js` as PID 1 so shutdown signals reach the app.
* **Docker Compose Recipe**: Ready-made `docker-compose.yml` for departmental deployments (named data volume, restart policy, `TRUST_PROXY` toggle, one-line update flow).
* **Automated Publishing**: GitHub Actions workflow builds and pushes multi-architecture (amd64 + arm64) images to GitHub Container Registry on every push to `main` — tagged `latest`, `sha-<commit>` for pinning/rollback, and semver tags on `v*` releases. Pull requests build the image as a smoke check without publishing.
* **Health Endpoint**: New unauthenticated `GET /api/health` liveness probe for containers, orchestrators and uptime monitors.
* **Graceful Shutdown**: On `SIGTERM`/`SIGINT` the server flushes session state, stops accepting connections, and exits once in-flight requests drain (with a 5-second hard-stop safety net) — so container stops and restarts never lose data.
* **Docs**: README gains a "Deploy with Docker" section (quick start, compose, volume backups, update and rollback guidance, registry/tagging reference).

## [2026-07-05] - Production Hardening

* **Forced First-Login Password Rotation**: Provisional passwords — the seeded default accounts, admin-created accounts, admin password resets, and bulk-generated temporary passwords — must now be changed at first sign-in. Enforcement is server-side: every API call except the rotation flow itself is rejected until the owner sets their own password, so a known default credential cannot be used to read or change data. The UI presents a dedicated, non-dismissable "Set a New Password" modal and only loads the app once the rotation completes.
Expand Down
36 changes: 36 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# SimHub — lightweight production image.
#
# The app has no build step and a single runtime dependency (Express), so a
# plain Alpine Node base keeps the final image small (~60 MB). Everything
# mutable lives under /app/data, which should be mounted as a volume.

FROM node:20-alpine

ENV NODE_ENV=production
WORKDIR /app

# Install production dependencies first so this layer only rebuilds when the
# dependency manifest changes, not on every source edit.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

# Application source (`.dockerignore` excludes tests, docs, git metadata and
# any local data directory).
COPY server.js seed.js ./
COPY public ./public

# Persistent flat-file storage. Pre-create it owned by the unprivileged
# `node` user so named volumes inherit sane ownership on first use.
RUN mkdir -p data && chown -R node:node /app
USER node
VOLUME ["/app/data"]

EXPOSE 3000

# Liveness probe against the unauthenticated /api/health endpoint.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.status === 200 ? 0 : 1)).catch(() => process.exit(1))"

# Run node directly (not via npm) so SIGTERM reaches the server process and
# the graceful-shutdown handler in server.js can flush state.
CMD ["node", "server.js"]
89 changes: 87 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ Honesty helps you evaluate. SimHub does not currently do: manikin/hardware contr

## 🚀 Getting started (about 10 minutes)

You do not need to be a developer. If you can install a program and copy a few commands into a terminal, you can run SimHub. There is no database server to set up and no build step — one small dependency and it runs.
There are two ways to run SimHub:

* **Directly with Node.js** (below) — the quickest way to try it out on your own machine or a test environment.
* **As a Docker container** (see [Deploy with Docker](#-deploy-with-docker)) — the recommended way to run it for a department: a small, self-contained image with your data kept safely in a volume.

You do not need to be a developer for either. If you can install a program and copy a few commands into a terminal, you can run SimHub. There is no database server to set up and no build step — one small dependency and it runs.

### 1. Install Node.js

Expand Down Expand Up @@ -117,14 +122,91 @@ These initial passwords are **provisional**: each account must set its own passw

---

## 🐳 Deploy with Docker

The published container image is the easiest way to run SimHub reliably on a departmental server, a NAS, or even a Raspberry Pi (it is built for both x86 and ARM). It is small (~60 MB), runs as an unprivileged user, reports its own health, and shuts down gracefully so no data is lost on restarts.

### Prerequisite

[Docker](https://docs.docker.com/get-docker/) (Docker Desktop on Windows/macOS, Docker Engine on Linux).

### Quick start — one command

```bash
docker run -d --name simhub \
-p 3000:3000 \
-v simhub-data:/app/data \
--restart unless-stopped \
ghcr.io/authortom/simhub:latest
```

Open **http://localhost:3000**, sign in with the seeded accounts (see [First sign-in](#first-sign-in)), and optionally load the worked example scenario:

```bash
docker exec simhub node seed.js
```

### Recommended — Docker Compose

The repository ships a ready-made [docker-compose.yml](docker-compose.yml). Copy it (or clone the repo) onto the server, then:

```bash
docker compose up -d # start
docker compose exec simhub node seed.js # optional: load the example scenario
docker compose logs -f simhub # watch the logs
```

Compose gives you a declarative record of your deployment (port, volume, environment) that you can keep in your team's documentation. Uncomment `TRUST_PROXY: "1"` in the file when running behind a reverse proxy.

### Your data lives in the volume

All scenarios, programmes, users, and sessions are stored in the `simhub-data` named volume — **the container itself is disposable**. You can delete and recreate the container (or update the image) without losing anything. Back the volume up either with the in-app Admin **Export** button, or at the infrastructure level:

```bash
docker run --rm -v simhub-data:/data -v "$PWD":/backup alpine \
tar czf /backup/simhub-data-backup.tar.gz -C /data .
```

### Updating to a new version

```bash
docker compose pull && docker compose up -d
```

That's it — the new container starts against the same data volume, and persisted sessions mean your faculty aren't even signed out. To be able to roll back, deploy a pinned tag (`ghcr.io/authortom/simhub:sha-<commit>` or a release version) instead of `latest`, and change the tag in `docker-compose.yml` when you upgrade.

### Building the image yourself

If you prefer not to pull the published image (e.g. on an air-gapped network), build it from source:

```bash
git clone https://github.com/authorTom/simhub.git
cd simhub
docker build -t simhub .
docker run -d --name simhub -p 3000:3000 -v simhub-data:/app/data --restart unless-stopped simhub
```

### Where images are published

Every push to `main` automatically builds and publishes a fresh multi-architecture image to **GitHub Container Registry** via the included [workflow](.github/workflows/docker-publish.yml):

| Tag | Meaning |
| :--- | :--- |
| `ghcr.io/authortom/simhub:latest` | Current state of `main` |
| `ghcr.io/authortom/simhub:sha-<commit>` | Immutable build of a specific commit (best for pinning/rollback) |
| `ghcr.io/authortom/simhub:<version>` | Published when a `v*` release tag is pushed |

---

## 🏥 Running SimHub for a department

For a single sim suite, `npm start` on any spare machine (Windows, macOS, Linux — modest hardware is fine) and browsing to its address is genuinely enough. For a shared departmental installation:
For a single sim suite, the Docker quick start above (or `npm start` on any spare machine — modest hardware is fine) is genuinely enough. For a shared departmental installation:

* **Put it behind HTTPS**: run a reverse proxy (nginx, Caddy, IIS) in front of SimHub and terminate TLS there. Sign-in uses bearer tokens and assumes an encrypted transport.
* **Set `TRUST_PROXY=1`** when behind a proxy so login rate-limiting sees real client addresses. Leave it unset when clients connect directly.
* **Protect the `data/` folder**: it holds password hashes and session tokens. It is never served over the web, but restrict filesystem permissions to the service account and include it in backups.
* **Restarts are painless**: active sign-ins persist across restarts and redeploys, so updating SimHub does not log your faculty out mid-session.
* **Monitoring**: `GET /api/health` is an unauthenticated liveness endpoint for uptime checks and container orchestrators (the Docker image already uses it for its built-in healthcheck).
* **Port**: set the `PORT` environment variable to change from the default `3000`.

Security features already built in: salted scrypt password hashing, brute-force login throttling, 8-hour sliding sessions with revocation on password change or account removal, server-side role enforcement, path-traversal and stored-XSS protections, and last-admin lockout guards.
Expand All @@ -150,6 +232,7 @@ The UI tests and screenshots need Google Chrome installed in a standard location
```text
simhub/
├── .github/ # Issue templates
│ └── workflows/ # CI: builds & publishes the Docker image
├── data/ # Flat-file JSON database (git-ignored)
│ ├── scenarios/ # One file per scenario
│ ├── programmes/ # Programme tracks
Expand All @@ -162,6 +245,8 @@ simhub/
│ └── index.html # Application shell
├── CHANGELOG.md # Full change history
├── CONTRIBUTING.md # Contribution guide
├── Dockerfile # Production container image
├── docker-compose.yml # Departmental deployment recipe
├── LICENSE # MIT licence
├── scenario_template.md # ASPiH scenario blueprint (reference)
├── seed.js # Example dataset generator
Expand Down
30 changes: 30 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# SimHub — departmental deployment.
#
# docker compose up -d start (pulls the published image)
# docker compose pull && docker compose up -d update to latest
# docker compose exec simhub node seed.js load the example scenario
#
# To build from local source instead of pulling the published image,
# comment out `image:` and uncomment `build: .`

services:
simhub:
image: ghcr.io/authortom/simhub:latest
# build: .
container_name: simhub
restart: unless-stopped
ports:
# host:container — change the left side to serve on a different port
- "3000:3000"
volumes:
# All scenarios, programmes, users and sessions live here.
# A named volume survives image updates; back it up regularly.
- simhub-data:/app/data
environment:
# Uncomment when running behind a reverse proxy (nginx, Caddy, Traefik)
# so login rate-limiting sees real client IPs:
# TRUST_PROXY: "1"
NODE_ENV: production

volumes:
simhub-data:
21 changes: 20 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,12 @@ function requireScenarioAccess(req, res, next) {
return res.status(403).json({ error: 'Forbidden. You do not have permission to modify scenarios.' });
}

// Unauthenticated liveness probe for containers, orchestrators and uptime
// monitors. Deliberately reveals nothing beyond process health.
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});

// --- AUTH ENDPOINTS ---

app.post('/api/login', async (req, res) => {
Expand Down Expand Up @@ -1293,6 +1299,19 @@ app.use((err, req, res, next) => {
res.status(status).json({ error: message });
});

app.listen(PORT, () => {
const server = app.listen(PORT, () => {
console.log(`SimHub Server running on http://localhost:${PORT}`);
});

// Containers and process managers stop the app with SIGTERM/SIGINT: flush
// session state (captures sliding-expiry updates since the last sweep) and
// stop accepting connections, then exit once in-flight requests drain.
['SIGTERM', 'SIGINT'].forEach(sig => {
process.on(sig, () => {
console.log(`Received ${sig}, shutting down gracefully...`);
try { saveSessions(); } catch { /* best effort on the way out */ }
server.close(() => process.exit(0));
// Hard stop if a connection refuses to drain before the runtime kills us.
setTimeout(() => process.exit(0), 5000).unref();
});
});
Loading