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

{
"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)