fangorn/huorn-minecraft
public
# 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 hashuorn.use.dockerANDdocker.enabledis true, use docker. Otherwise, ifplain.enabledis true, use plain. Otherwise, deny terminal creation. ThedefaultBackendfield controls what players get when they qualify for multiple backends (i.e., if a player hashuorn.use.dockerbutdefaultBackendis"plain", they get plain unless a future per-player preference mechanism is added).commandBlocklist— matched against newline-delimited input. The RustTerminalSessionwrapper accumulates bytes until\ror\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: eachTerminalSessionwrapper tracks the timestamp of the lastwrite()call. A background thread checks all sessions periodically (every 60 seconds) and kills any that exceed the timeout. The Rust side fires theTIMEOUTevent and callskill()on the session; Java detects the dead session on its next tick and logsTIMEOUTto the audit log.maxTerminalsTotal— server-wide cap, enforced Java-side. Terminal tracking uses aConcurrentHashMap<UUID, AtomicInteger>for per-player counts alongside a globalAtomicIntegerfor total count. Both structures are thread-safe since the server-side block interaction handler may be invoked concurrently. BothmaxTerminalsPerPlayerandmaxTerminalsTotalare checked in the server-side block interaction handler before callingcreate_terminalvia JNI. Counts are decremented on session end (normal close, timeout, or admin kill).- Docker image — defaults to
ubuntu:24.04for now._image_futuredocuments the eventualregistry.fangorn.ioimage; 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.rscurrently owns PTY spawning directly. That code moves intoPlainShellBackend;terminal.rsholds aBox<dyn TerminalSession>instead.- Java side:
TerminalBlockEntitygets abackendfield (persisted to NBT). On activation, mod checks config default and player permissions to select backend. - JNI
create_terminalgains abackend: Stringparameter. Rust looks up backend by name, callsspawn(). is_available()called at server startup and on/huorn status. This only checks — it does not pull images. A separateprepare()method onDockerBackendpulls the image if missing, invokable via a future/huorn prepare dockeradmin 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 todockerCLI. Uses HTTP over unix socket viahyper+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).DockerSessionbridges the attach stream toread/writetrait methods. - Container lifecycle tied to session: created on
spawn(), removed onkill()orDrop.
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). Thehandleidentifies which session's log context to use.nativeAuditEventGlobal(eventType, jsonPayload)— for events with no session (BACKEND_ERROR, orADMIN_KILLwhen 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
TerminalSessionwrapper logsCOMMANDandBLOCKEDon every line of input (accumulated until\r/\n). Single point where input flows, guarantees coverage. - Java side: Calls
nativeAuditEventornativeAuditEventGlobalfor lifecycle events it controls. Does not write to the log file directly. - Log rotation: Deferred to external tools (e.g.,
logrotateon Linux). The Rust writer opens the file in append mode on each write, so rotation-by-rename works without signal handling. The config'slogFilepath is the only knob. File growth is a known limitation for V1 — documented in server admin notes. /huorn auditreads 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)