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 platformsNativeLoader.java: library name resolutionlib.rsJNI_OnLoad: class path fromio/fangorn/alacrittymc/nativelib/NativeTerminaltoio/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:
- Anvil CI detects merge to
main - Determines next CalVer version: reads latest Anvil release tag, increments build number (e.g.,
2026.03.1->2026.03.2; new month resets to2026.04.1) - Injects version into
gradle.properties - Builds native libraries via
build_natives.sh(cross-compile for 4 platform targets: macOS aarch64/x86_64, Linux aarch64/x86_64). Windowsx86_64native is a pre-built artifact checked intonatives/— no Windows cross-compilation in CI until a Windows CI runner is available. - Runs
./gradlew build— producesfabric-YYYY.MM.BUILD.jarandforge-YYYY.MM.BUILD.jar - Runs tests (Rust unit + Gradle tests)
- Creates Anvil release with both JARs attached, auto-generated changelog from commit messages since last release
- 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:
allowedPlayersUUID list andopsAlwaysAllowedfrom config — LuckPerms replaces both.
Implementation
- New
permissionspackage in common module with platform-abstracted permission checker - Architectury’s
@ExpectPlatformpattern 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
commandpackage in common module - One class per subcommand
HuornCommand.javaas 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
{
"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)