Skip to content

Ephemeral instances for ssh and web#49

Open
Monster0506 wants to merge 89 commits into
developfrom
feat/ssh-instances
Open

Ephemeral instances for ssh and web#49
Monster0506 wants to merge 89 commits into
developfrom
feat/ssh-instances

Conversation

@Monster0506

@Monster0506 Monster0506 commented Jul 22, 2026

Copy link
Copy Markdown
Member

Below is written by copilot 👇 Take it with a grain of salt, let me know if you have any questions on anything :)

This pull request introduces several significant changes focused on modernizing the development environment, improving database schema management, and enhancing infrastructure for container orchestration and image registry. The major themes are migration to Bun as the main JavaScript runtime, database schema updates, and expanded Docker Compose orchestration.

New standalone service (orchestrator.js + server.js, Bun) that owns the full lifecycle of both SSH and web challenge containers:

  • SSH instances: CreateSSHInstance(uid, image_ref) picks a free port in the configured range, generates a random 20-char password, creates the container with AutoRemove: true and a real expiry (khi.expires_at label + an in-process setTimeout that self-stops the container), and reconciles on boot (ReconcileOnBoot()) so a restart of the orchestrator doesn't leave already-expired containers running forever.
  • Web instances: CreateWebInstance(challengeId, image_ref) resolves the image's exposed port automatically — reading Config.ExposedPorts, or a khi.port label as an escape hatch for images (e.g. database base images) that EXPOSE more than one port across their layer history, which Docker doesn't let you undo. Fails loudly on ambiguity instead of guessing.
  • Per-instance network isolation: every instance gets its own freshly created bridge network (createInstanceNetwork()/removeInstanceNetwork()), not a shared one — so containers can't reach each other's instances, and orphaned networks get swept on boot (cleanupOrphanedNetworks()).
  • Resource caps: NanoCpus/Memory/PidsLimit on every container, defaulting from env vars but overridable per-image via khi.pids_limit/khi.mem_bytes labels (getResourceLimits()) — needed once we hit real challenges (Puppeteer-based ones in particular) that legitimately need more headroom than the default.
  • All of this talks to Docker exclusively through docker-socket-proxy, scoped in docker-compose.yml to CONTAINERS, POST, IMAGES, NETWORKS only — the orchestrator has no broader access to the host's Docker socket than it needs.

Security hardening

  • Image allowlisting: both CreateSSHInstance and CreateWebInstance reject any image_ref that doesn't start with the configured SSH_IMAGE_PREFIX/WEB_IMAGE_PREFIX (default khi-ssh/, khi-web/) before ever touching Docker — an admin (or a compromised admin session) can't point an instance at an arbitrary image.
  • Registry pull with local fallback: resolveImage() tries a registry pull first (with basic-auth via X-Registry-Auth), and falls back to a local image of the same name only if that specific pull fails — so dev/local testing doesn't require a registry at all, but production always prefers the authoritative signed source.
  • Non-root wrapper images: every challenge image gets wrapped by a build step (ssh-challenge-wrapper/Dockerfile.wrapper, web-challenge-wrapper/Dockerfile.wrapper) that adds a real ctf-player user rather than running challenges as root. The SSH wrapper preserves /etc/passwd's original file mode across the useradd/adduser step (captured before, restored after) since some base images ship non-default permissions there and silently loosening them would be its own vuln; it also starts cron/crond and /opt/khi-daemon if either happens to be present in the base image, so challenge-specific background services still work under the wrapper.
  • Cross-type single-instance enforcement: a player can only ever have one live instance at a time, whether it's an nc instance or a full SSH instance — CreateInstance checks GetActiveSSHInstance and stops it before creating an nc instance, and the SSH path does the mirror check, so switching challenge types can't silently leave a second container running per player.
  • Per-challenge connection scoping: /api/cinstance and /api/sshinstance now take ?cid= and only report a session as active if it belongs to the challenge being viewed (with other_active surfaced separately) — previously a player's connection info could leak across challenges just by having any active instance.
  • Boot-time reconciliation: ReconcileInstances() (app-side, complementing the orchestrator's own ReconcileOnBoot) purges ssh_instance_sessions/web_instances rows whose containers no longer actually exist, and EnsureWebInstance self-heals via isContainerAlive() before handing back a "your instance is running" response — fixes a real bug where a container that died (OOM-killed, crashed, manually removed) left an orphaned DB row that made the UI claim an instance was live when it wasn't.

Docker registry (registry.hacksu.com / dev.registry.hacksu.com)

  • registry/docker-compose.yml: a self-hosted registry:2 behind htpasswd auth, credentials generated from REGISTRY_USER/REGISTRY_PASSWORD at container start (refuses to start at all if either is unset), with REGISTRY_STORAGE_DELETE_ENABLED: true so old/broken image tags can actually be deleted (registry GC needs this enabled to reclaim blob storage, otherwise deletes are rejected outright).
  • Split into separate prod (registry.hacksu.com) and dev (dev.registry.hacksu.com) hosts with independent credentials, so challenge-authoring iteration never touches the real competition registry.
  • Admin UI (src/lib/server/registry.ts + challenge.form.svelte) now pulls the image dropdown for both SSH and web challenge images directly from the registry's catalog API instead of free-text entry, with manual entry kept as a fallback if the registry is unreachable.

CI / build pipeline (kent-hack-it-challenges/.github/workflows/)

Two workflow_dispatch workflows (build-and-push-ssh-image.yml, build-and-push-web-image.yml), both taking a dockerfile_path (so CI builds one specific challenge, not the whole repo) and a slug:

  • SSH pipeline: builds the challenge's own Dockerfile, then always wraps it through ssh-challenge-wrapper (adds sshd + non-root user, as above) before pushing.
  • Web pipeline: builds the challenge's Dockerfile, validates it resolves to exactly one port (via khi.port label or a single EXPOSE, failing the build with a clear error otherwise), inspects whether the image already declares a non-root USER and only applies the wrapper if it doesn't (avoids double-wrapping images that already do the right thing).
  • Both take an environment choice (dev/prod, defaulting to dev) that switches the target registry host and pulls the matching DEV_REGISTRY_USER/DEV_REGISTRY_PASSWORD or REGISTRY_USER/REGISTRY_PASSWORD secrets accordingly — same workflow file, no duplication, and dev runs can't accidentally land in the prod registry.

Admin UI

  • New Instances tab (d.instances.svelte) covering both SSH and web instances together, with a chip-style type filter, Stop/Restart actions, and correct per-type ID/time-remaining display (previously SSH instances weren't visible in the admin view at all).
  • DeleteChallenge now tears down any live instance for that challenge before removing the DB row, instead of leaving a running container with no owning challenge behind.
  • Fixed a silent-failure bug where duplicate-flag/duplicate-name constraint violations on challenge create/update just failed with a generic error — now surfaces the real Postgres constraint name so the admin knows what actually collided.

Net effect

Players get isolated, resource-capped, time-boxed containers on their own network instead of sharing a fixed pool of processes; challenge images are built, hardened, and versioned through CI instead of hand-pushed; and the admin surface for managing all of this (instances, images, deletion) actually reflects reality instead of drifting from it.

Database Schema and Migrations

  • Added new Drizzle migration files for event configuration, SSH/web instance session tracking, and challenge image references, and updated the migration journal.
  • Added a dedicated migration service and Dockerfile for running Drizzle migrations in the deployment pipeline.
  • Updated drizzle.config.ts to remove debug logging and set ssl: false for the database connection.

Docker Compose Orchestration Enhancements

  • Introduced new services in docker-compose.yml for a Docker socket proxy, SSH orchestrator, and migration runner, with appropriate networks and environment variables for orchestration and image registry access.
  • Added a standalone Docker Compose file for a local Docker registry with authentication support.

Infrastructure and Build Improvements

  • Refactored the main app and handler Dockerfiles for multi-stage builds, reducing unnecessary dependencies and improving build performance.
  • Cleaned up Postgres initialization scripts and removed hardcoded SQL seeding in favor of Drizzle migrations.

UI/Frontend Updates

  • Improved color scheme handling for light and dark modes in src/app.css.
  • Enhanced the authentication client setup to use the inferAdditionalFields plugin for better type inference.

Migration to Bun Runtime

  • Replaced Node.js with Bun as the primary JavaScript runtime for the main app and handler, updating Dockerfile, handler/Dockerfile, and related supervisor configs to use Bun for installing dependencies and running the server.
  • Updated development instructions and scripts to use Bun instead of npm, including changes in README.md and package.json.

@Monster0506
Monster0506 marked this pull request as ready for review July 23, 2026 01:34
@Monster0506
Monster0506 requested a review from EnderHubris July 23, 2026 01:34
@YoyoJesus
YoyoJesus self-requested a review July 23, 2026 14:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant