ref:main
# Huorn: Distribution, Admin & Security Design
**Date:** 2026-03-20
**Status:** Approved
**Scope:** Private release infrastructure, admin management, pluggable sandbox architecture
## Context
Huorn is a Minecraft mod that places real terminal emulators in-game as blocks, powered by Alacritty's terminal library. Phase 1 (Rust core + JNI bridge + working in-game terminal) is complete. This spec covers everything needed to make the mod installable and manageable by real server admins.
**Target audience:** Private release first (known players, owned servers). Public distribution (Modrinth/CurseForge) deferred.
**Runtime:** Minecraft 1.20.1, Java 17 (MC 1.20.1 minimum), Fabric Loader 0.15.3+ / Forge 47.2.0+, Architectury API 9.2.14+.
## 1. Rename
The project is renamed from `alacrittymc` / `alacritty-minecraft` to `huorn` / `huorn-minecraft`. The rule: things inside the Minecraft ecosystem (mod ID, package, commands, permissions) use `huorn`. Things outside Minecraft (native library, Rust crate, Anvil repo) use `huorn-minecraft` to disambiguate within Fangorn's portfolio.
| What | Old | New |
|------|-----|-----|
| Mod ID | `alacrittymc` | `huorn` |
| Package | `io.fangorn.alacrittymc` | `io.fangorn.huorn` |
| Command | `/alacritty` | `/huorn` |
| Permission nodes | `alacrittymc.*` | `huorn.*` |
| Config file | `config/alacrittymc.json` | `config/huorn.json` |
| Display name | "Alacritty MC" | "Huorn" |
| Mixin config | `alacrittymc.mixins.json` | `huorn.mixins.json` |
| Access widener | `alacrittymc.accesswidener` | `huorn.accesswidener` |
| Native lib | `libalacritty_minecraft.*` | `libhuorn_minecraft.*` |
| Rust crate | `alacritty-minecraft` | `huorn-minecraft` |
| Anvil repo | `fangorn/alacritty-minecraft` | `fangorn/huorn-minecraft` |
**Preserved:** `alacritty_terminal` crate dependency (upstream library, not ours). Comments explaining Huorn uses Alacritty under the hood.
**Rename surface area** (spans Rust, shell, and Java — must all change together):
- `Cargo.toml` `[lib]` section: `name = "huorn_minecraft"`
- `build_natives.sh`: output filenames for all platforms
- `NativeLoader.java`: library name resolution
- `lib.rs` `JNI_OnLoad`: class path from `io/fangorn/alacrittymc/nativelib/NativeTerminal` to `io/fangorn/huorn/nativelib/NativeTerminal`
**Timing note:** The Rust crate renames with this work. The Anvil repo rename (`fangorn/alacritty-minecraft` -> `fangorn/huorn-minecraft`) is deferred and coordinated separately. The crate name in `Cargo.toml` does not need to match the repo name.
**Config migration:** The config file path changes from `config/alacrittymc.json` to `config/huorn.json`. The mod reads only the new path and creates defaults if absent. The old file is ignored — no auto-migration. A log message at startup warns if `config/alacrittymc.json` exists, telling the admin to manually migrate settings.
## 2. CI/Release Pipeline
**Trigger:** PR merged to `main`.
**Flow:**
1. Anvil CI detects merge to `main`
2. Determines next CalVer version: reads latest Anvil release tag, increments build number (e.g., `2026.03.1` -> `2026.03.2`; new month resets to `2026.04.1`)
3. Injects version into `gradle.properties`
4. Builds native libraries via `build_natives.sh` (cross-compile for 4 platform targets: macOS aarch64/x86_64, Linux aarch64/x86_64). Windows `x86_64` native is a pre-built artifact checked into `natives/` — no Windows cross-compilation in CI until a Windows CI runner is available.
5. Runs `./gradlew build` — produces `fabric-YYYY.MM.BUILD.jar` and `forge-YYYY.MM.BUILD.jar`
6. Runs tests (Rust unit + Gradle tests)
7. Creates Anvil release with both JARs attached, auto-generated changelog from commit messages since last release
8. Tags the commit with the version
**First release fallback:** If no prior Anvil release exists, the version-bump script defaults to `YYYY.MM.1`.
**Requires:**
- Anvil CI config file
- Version-bump script that reads the latest release and computes next CalVer
- Rust cross-compilation toolchains in CI (Docker-based via existing `Cross.toml`)
- Gradle configured to accept version as build property (`-Pversion=YYYY.MM.BUILD`)
## 3. Permission System (LuckPerms Integration)
### Permission Nodes
| Node | Default | Description |
|------|---------|-------------|
| `huorn.use` | op | Place and interact with terminal blocks |
| `huorn.use.docker` | op | Use Docker-backed terminals |
| `huorn.admin.reload` | op | Reload config at runtime |
| `huorn.admin.list` | op | List all active terminals on the server |
| `huorn.admin.kill` | op | Force-kill any player's terminal |
| `huorn.admin.audit` | op | View audit log |
### Integration
- **Fabric:** Integrate via `fabric-permissions-api` (the lightweight API that LuckPerms implements). No hard dependency on LuckPerms — any permissions mod that implements the Fabric Permissions API works.
- **Forge:** Use Forge's built-in `PermissionAPI`.
- **Fallback:** If no permissions mod is installed, fall back to vanilla op level (configurable: `"defaultOpLevel": 4`).
- **Removed:** `allowedPlayers` UUID list and `opsAlwaysAllowed` from config — LuckPerms replaces both.
### Implementation
- New `permissions` package in common module with platform-abstracted permission checker
- Architectury's `@ExpectPlatform` pattern to bridge Fabric Permissions API and Forge PermissionAPI
- **Server-side enforcement:** All permission checks run server-side. The current `TerminalBlockEntity.onPlayerInteract()` runs client-side — this must be refactored to a server-side block interaction handler. The server validates permissions and sends the result to the client, never the other way around.
## 4. Admin Commands
### Command Tree

/huorn reload — Reload config from disk /huorn list — List all active terminals (player, location, backend, uptime) /huorn kill <player|all> — Force-kill terminal(s) /huorn audit [player] [--last N] — Show recent audit log entries /huorn status — Server-wide stats (active terminals, backend availability)

### Registration
- Registered via Brigadier (Minecraft's built-in command framework), works on both Fabric and Forge
- Each subcommand checks its permission node before executing
- Commands work in-game, from server console, and via RCON — Brigadier provides this for free
### Implementation
- New `command` package in common module
- One class per subcommand
- `HuornCommand.java` as root that registers the tree
- Commands call into the same service layer that config and block entity use — no duplicate logic
## 5. Config File
**Location:** `config/huorn.json`
```json
{
"server": {
"enableOnServers": false,
"maxTerminalsPerPlayer": 4,
"maxTerminalsTotal": 32,
"idleTimeoutMinutes": 30,
"defaultBackend": "plain"
},
"backends": {
"plain": {
"enabled": true,
"allowedShells": ["/bin/bash", "/bin/zsh"]
},
"docker": {
"enabled": false,
"image": "ubuntu:24.04",
"_image_future": "registry.fangorn.io/huorn/sandbox:latest",
"memoryLimit": "256m",
"cpuLimit": 0.5,
"networkEnabled": false,
"mountPaths": []
}
},
"security": {
"commandBlocklist": ["rm -rf /", ":(){ :|:& };:"],
"auditLog": {
"enabled": true,
"logFile": "logs/huorn-audit.log",
"logCommands": true,
"logConnections": true
}
},
"display": {
"fontSize": 14.0,
"craftable": true
}
}

Design Decisions

  • Structured by concern — server limits, backend config, security, display are separate sections
  • defaultBackend — which sandbox new terminals use. Backend resolution logic: if player has huorn.use.docker AND docker.enabled is true, use docker. Otherwise, if plain.enabled is true, use plain. Otherwise, deny terminal creation. The defaultBackend field controls what players get when they qualify for multiple backends (i.e., if a player has huorn.use.docker but defaultBackend is "plain", they get plain unless a future per-player preference mechanism is added).
  • commandBlocklist — matched against newline-delimited input. The Rust TerminalSession wrapper accumulates bytes until \r or \n, then matches the accumulated line as a substring against each blocklist entry. Not a security boundary (determined users can bypass via escape sequences, hex encoding, etc.), but catches accidents and flags intent in the audit log.
  • idleTimeoutMinutes — auto-kills terminals with no input. Enforced on the Rust side: each TerminalSession wrapper tracks the timestamp of the last write() call. A background thread checks all sessions periodically (every 60 seconds) and kills any that exceed the timeout. The Rust side fires the TIMEOUT event and calls kill() on the session; Java detects the dead session on its next tick and logs TIMEOUT to the audit log.
  • maxTerminalsTotal — server-wide cap, enforced Java-side. Terminal tracking uses a ConcurrentHashMap<UUID, AtomicInteger> for per-player counts alongside a global AtomicInteger for total count. Both structures are thread-safe since the server-side block interaction handler may be invoked concurrently. Both maxTerminalsPerPlayer and maxTerminalsTotal are checked in the server-side block interaction handler before calling create_terminal via JNI. Counts are decremented on session end (normal close, timeout, or admin kill).
  • Docker image — defaults to ubuntu:24.04 for now. _image_future documents the eventual registry.fangorn.io image; code ignores underscore-prefixed keys

6. Pluggable Sandbox Architecture

Rust Traits

pub trait TerminalBackend: Send + Sync {
fn spawn(&self, config: &BackendConfig) -> Result<Box<dyn TerminalSession>>;
fn name(&self) -> &'static str;
fn is_available(&self) -> Result<bool>;
}
pub trait TerminalSession: Send {
fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
fn write(&mut self, data: &[u8]) -> Result<usize>;
fn resize(&mut self, cols: u16, rows: u16) -> Result<()>;
fn kill(&mut self) -> Result<()>;
fn is_alive(&self) -> bool;
}

Backends (V1)

| Backend | spawn() via | is_available() checks | |---------|---------------|------------------------| | PlainShellBackend | alacritty_terminal::tty (existing code) | Shell binary exists in allowedShells | | DockerBackend | Docker Engine API (unix socket) | Socket exists, daemon responds, image present locally |

Integration

  • terminal.rs currently owns PTY spawning directly. That code moves into PlainShellBackend; terminal.rs holds a Box<dyn TerminalSession> instead.
  • Java side: TerminalBlockEntity gets a backend field (persisted to NBT). On activation, mod checks config default and player permissions to select backend.
  • JNI create_terminal gains a backend: String parameter. Rust looks up backend by name, calls spawn().
  • is_available() called at server startup and on /huorn status. This only checks — it does not pull images. A separate prepare() method on DockerBackend pulls the image if missing, invokable via a future /huorn prepare docker admin command or manually by the admin (docker pull).

Docker Implementation

  • Communicates with Docker Engine API over unix socket (/var/run/docker.sock). No shelling out to docker CLI. Uses HTTP over unix socket via hyper + hyperlocal.
  • Creates container with: configured image, memory/CPU limits, no network (unless enabled), PTY attached via docker attach stream.
  • Container runs a shell (/bin/bash). DockerSession bridges the attach stream to read/write trait methods.
  • Container lifecycle tied to session: created on spawn(), removed on kill() or Drop.

Future Backends (Deferred)

MicroVM (Firecracker/Cloud Hypervisor), Apple Hypervisor, Hyper-V — each implements the same two traits. No changes to Java, JNI, or terminal.rs.

7. Audit Logging

Format

JSONL (one JSON object per line) in logs/huorn-audit.log:

{"ts":"2026-03-20T14:32:01Z","event":"CONNECT","player":"uuid","name":"Steve","backend":"docker","location":"world:123,64,-456"}
{"ts":"2026-03-20T14:32:05Z","event":"COMMAND","player":"uuid","name":"Steve","input":"ls -la"}
{"ts":"2026-03-20T15:02:01Z","event":"TIMEOUT","player":"uuid","name":"Steve","reason":"idle_30m"}

Event Types

| Event | When | Details | |-------|------|---------| | CONNECT | Player opens terminal | Backend type, block location | | DISCONNECT | Player closes normally | Session duration | | COMMAND | Input sent to shell | The input string (configurable) | | TIMEOUT | Idle timeout kills session | Timeout duration | | ADMIN_KILL | Admin runs /huorn kill | Which admin, target player | | BLOCKED | Input matched blocklist | The blocked input | | BACKEND_ERROR | Backend failed to spawn | Backend name, error message |

Implementation

  • Single writer architecture: All audit log writes go through Rust. Two JNI entry points:
    • nativeAuditEvent(handle, eventType, jsonPayload) — for session-bound events (CONNECT, DISCONNECT, TIMEOUT, ADMIN_KILL). The handle identifies which session's log context to use.
    • nativeAuditEventGlobal(eventType, jsonPayload) — for events with no session (BACKEND_ERROR, or ADMIN_KILL when the session is already dead). Writes directly to the audit log without session context. Rust appends to the log file. This eliminates the data-corruption risk of two writers appending to the same file.
  • Rust side: The TerminalSession wrapper logs COMMAND and BLOCKED on every line of input (accumulated until \r/\n). Single point where input flows, guarantees coverage.
  • Java side: Calls nativeAuditEvent or nativeAuditEventGlobal for lifecycle events it controls. Does not write to the log file directly.
  • Log rotation: Deferred to external tools (e.g., logrotate on Linux). The Rust writer opens the file in append mode on each write, so rotation-by-rename works without signal handling. The config's logFile path is the only knob. File growth is a known limitation for V1 — documented in server admin notes.
  • /huorn audit reads the file and formats for in-game display with optional player filter and --last N.

Explicitly Deferred

  • MicroVM / Apple Hypervisor / Hyper-V backends
  • Custom sandbox image on registry.fangorn.io
  • Modrinth / CurseForge listing
  • Public documentation and update checker
  • Anvil repo rename (coordinate separately)