@@ -1,0 +1,257 @@
# 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
```rust
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`:
```json
{"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)