Events
Register listener objects with the EventBus. Methods annotated with @EventHandler and a single Event parameter are invoked when posted.
class ExampleListener {
@EventHandler
fun onEvent(event: Event) {
// handle event
}
}
Registration:
context.eventBus.register(ExampleListener())
Routing events require the ROUTING capability. Security events require the SECURITY capability.
To stop listening, call unregister with the same listener instance.
Security Invariants
This document defines the non-negotiable security invariants for v0.4.0 and links each invariant to concrete runtime guards and tests.
Invariant 1: Backend accepts proxy-origin traffic only
-
Backend proxy-token validation is strict (
v3only, required cert claims): -
backend-mod/src/main/kotlin/ru/hytalemodding/lineage/backend/security/TokenValidator.kt -
Referral source is validated against configured proxy endpoint:
-
backend-mod/src/main/kotlin/ru/hytalemodding/lineage/backend/LineageBackendMod.kt -
Unsafe downgrade is blocked by config (
enforce_proxymust remain true): -
backend-mod/src/main/kotlin/ru/hytalemodding/lineage/backend/config/BackendConfigLoader.kt
Tests:
-
backend-mod/src/test/kotlin/ru/hytalemodding/lineage/backend/security/TokenValidatorTest.kt -
backend-mod/src/test/kotlin/ru/hytalemodding/lineage/backend/handshake/HandshakeInterceptorTest.kt
Invariant 2: Backend command execution cannot bypass proxy
-
Backend command mirror is populated only from validated proxy snapshots:
-
expected sender check (
proxy), timestamp window check, replay check. -
Command dispatch is gated by deterministic policy:
-
player sender required, messaging enabled, registry synchronized, non-blank command.
Runtime:
-
backend-mod/src/main/kotlin/ru/hytalemodding/lineage/backend/command/ProxyCommandBridge.kt -
backend-mod/src/main/kotlin/ru/hytalemodding/lineage/backend/command/ProxyCommandDispatchPolicy.kt
Tests:
-
backend-mod/src/test/kotlin/ru/hytalemodding/lineage/backend/command/ProxyCommandBridgeTest.kt -
backend-mod/src/test/kotlin/ru/hytalemodding/lineage/backend/command/ProxyCommandDispatchPolicyTest.kt
Invariant 3: Routing decision is immutable after finalization
-
RoutingDecisionis one-shot by design and throws on second mutation.
Runtime:
-
api/src/main/kotlin/ru/hytalemodding/lineage/api/routing/RoutingDecision.kt
Tests:
-
proxy/src/test/kotlin/ru/hytalemodding/lineage/proxy/routing/RoutingDecisionInvariantTest.kt -
proxy/src/test/kotlin/ru/hytalemodding/lineage/proxy/routing/EventRouterTest.kt
Invariant 4: Replay is rejected inside control window
-
Control-plane replay guard:
-
shared/src/main/kotlin/ru/hytalemodding/lineage/shared/control/ControlReplayProtector.kt -
Backend handshake replay guard:
-
backend-mod/src/main/kotlin/ru/hytalemodding/lineage/backend/security/ReplayProtector.kt
Tests:
-
shared/src/test/kotlin/ru/hytalemodding/lineage/shared/control/ControlReplayProtectorTest.kt -
backend-mod/src/test/kotlin/ru/hytalemodding/lineage/backend/security/ReplayProtectorTest.kt
Invariant 5: Security failures do not silently downgrade behavior
-
Invalid security checks follow deterministic reject paths, not fallback execution.
-
Weak/default secrets are fail-fast at config load.
-
Legacy agent mode is removed from runtime and configuration.
Primary files:
-
shared/src/main/kotlin/ru/hytalemodding/lineage/shared/security/SecretStrengthPolicy.kt -
proxy/src/main/kotlin/ru/hytalemodding/lineage/proxy/config/TomlLoader.kt -
backend-mod/src/main/kotlin/ru/hytalemodding/lineage/backend/config/BackendConfigLoader.kt
Getting started
Lineage mods are plain JVM jars. A mod is discovered by scanning for the @LineageModInfo annotation and instantiating the annotated class.
Minimal mod
Kotlin:
@LineageModInfo(
id = "hello",
name = "Hello Mod",
version = "1.0.0",
apiVersion = "0.4.0",
authors = ["YourName"]
)
class HelloMod : LineageMod() {
override fun onEnable() {
context.logger.info("Hello from Lineage!")
}
}
Java:
@LineageModInfo(
id = "hello",
name = "Hello Mod",
version = "1.0.0",
apiVersion = "0.4.0",
authors = {"YourName"}
)
public final class HelloMod extends LineageMod {
@Override
public void onEnable() {
context.getLogger().info("Hello from Lineage!");
}
}
Capabilities
Mods run without privileged capabilities by default. Declare the minimum set you need:
Kotlin:
@LineageModInfo(
id = "hello",
name = "Hello Mod",
version = "1.0.0",
apiVersion = "0.4.0",
capabilities = [ModCapability.MESSAGING, ModCapability.PLAYERS]
)
class HelloMod : LineageMod()
Java:
@LineageModInfo(
id = "hello",
name = "Hello Mod",
version = "1.0.0",
apiVersion = "0.4.0",
capabilities = {ModCapability.MESSAGING, ModCapability.PLAYERS}
)
public final class HelloMod extends LineageMod {
}
Packaging
-
Build a jar that includes your mod class and resources.
-
Place the jar into
mods/next to the proxyconfig.toml. -
Per-mod data is stored under
mods/<mod-id>/.
Dependency
Lineage API is published on Maven Central.
Gradle Kotlin DSL:
dependencies {
implementation("ru.hytalemodding.lineage:api:0.4.0")
}
Gradle Groovy DSL:
dependencies {
implementation "ru.hytalemodding.lineage:api:0.4.0"
}
Maven:
<dependency>
<groupId>ru.hytalemodding.lineage</groupId>
<artifactId>api</artifactId>
<version>0.4.0</version>
</dependency>
You can still build from source and depend on the local :api module when needed.
Mod metadata
Metadata is provided via @LineageModInfo on the mod main class.
Fields:
-
id: lowercase identifier, 1-32 chars,[a-z0-9_-]+ -
name: human name, 1-64 chars,[A-Za-z0-9 _.-]+ -
version:MAJOR.MINOR.PATCH -
apiVersion:MAJOR.MINOR.PATCH -
authors: list of author names -
description: optional text -
dependencies: required dependencies -
softDependencies: optional dependencies -
capabilities: explicit access to privileged APIs -
website,license: optional strings
Capabilities:
-
COMMANDS -
MESSAGING -
PLAYERS -
BACKENDS -
ROUTING -
SECURITY -
PERMISSIONS -
SCHEDULER -
SERVICES
Dependency entries accept version constraints:
core
core>=1.2.0
worlds^2.0.0
If a required dependency is missing or does not satisfy the constraint, the mod will not load.
Proxy Auth, Join and Transfer Flow (v0.4.0)
This document describes the runtime flow exactly as implemented in proxy and backend-mod.
Source of truth
-
proxy/src/main/kotlin/ru/hytalemodding/lineage/proxy/net/QuicSessionHandler.kt -
proxy/src/main/kotlin/ru/hytalemodding/lineage/proxy/net/handler/ConnectPacketInterceptor.kt -
proxy/src/main/kotlin/ru/hytalemodding/lineage/proxy/net/handler/StreamBridge.kt -
proxy/src/main/kotlin/ru/hytalemodding/lineage/proxy/control/ControlPlaneService.kt -
proxy/src/main/kotlin/ru/hytalemodding/lineage/proxy/player/PlayerTransferService.kt -
proxy/src/main/kotlin/ru/hytalemodding/lineage/proxy/net/BackendAvailabilityTracker.kt -
backend-mod/src/main/kotlin/ru/hytalemodding/lineage/backend/LineageBackendMod.kt -
backend-mod/src/main/kotlin/ru/hytalemodding/lineage/backend/security/TokenValidator.kt -
backend-mod/src/main/kotlin/ru/hytalemodding/lineage/backend/control/BackendControlPlaneService.kt
1. Initial join and authenticated handshake
sequenceDiagram
autonumber
participant C as Client
participant P as Proxy QUIC listener
participant SI as Stream0 interceptor
participant PB as Proxy->Backend QUIC
participant B as Backend server
participant BM as Backend mod
C->>P: QUIC/TLS connect
P->>P: QuicSessionHandler.channelActive()
P->>P: capture client cert (if present)
C->>SI: Stream 0 Connect packet
SI->>SI: ConnectPacketInterceptor.channelRead()
SI->>SI: decode/validate Connect + routing decision
SI->>SI: issue proxy token (client cert + proxy cert)
SI->>SI: rewrite Connect referralSource/referralData
SI->>PB: ensure backend channel (with fallback policy)
PB->>B: Open backend QUIC/TLS
SI->>B: Forward modified Connect
B->>BM: PacketAdapters inbound Connect bridge
BM->>BM: validate referral source + TokenValidator.validate()
BM->>B: apply client cert attr + server cert
B->>C: continue AUTHENTICATED flow (identity/grant/token)
B-->>P: token validation notice (control-plane)
P->>P: ControlPlaneService.handleTokenValidation()
Key implementation points:
-
Stream
0interception and packet rewrite:ConnectPacketInterceptor. -
Proxy token injection:
TokenService.issueToken(...)fromConnectPacketInterceptor. -
Backend cert policy and ALPN checks before stream bridge:
QuicSessionHandler.connectBackend(...). -
Final backend-side validation and cert context apply:
LineageBackendMod.registerHandshakeBridge()+LineageBackendMod.onPlayerConnect().
2. Server transfer flow (/transfer)
sequenceDiagram
autonumber
participant A as Admin/Player command sender
participant P as Proxy command layer
participant TS as PlayerTransferService
participant CPS as ControlPlaneService (proxy)
participant BCM as BackendControlPlaneService
participant B as Current backend
participant C as Client
A->>P: /transfer <backend>
P->>TS: requestTransferDetailed(player, target)
TS->>TS: validate player/backend/status
TS->>CPS: sendTransferRequest(correlationId, referralData)
CPS->>BCM: CONTROL TRANSFER_REQUEST
BCM->>B: referToServer(proxyHost, proxyPort, referralData)
BCM-->>CPS: CONTROL TRANSFER_RESULT
C->>P: reconnect via proxy using referralData
P->>P: validate transfer token in Connect interceptor
P->>P: route to requested backend
Key implementation points:
-
Command entry:
TransferCommand.execute(...). -
Transfer request orchestration:
PlayerTransferService.requestTransferDetailed(...). -
Control-plane encode/send/verify:
ControlPlaneServiceandBackendControlPlaneService. -
Transfer token consume path:
ConnectPacketInterceptor.resolveBackend(...).
3. Backend status, fallback and reconnect behavior
sequenceDiagram
autonumber
participant BCM as BackendControlPlaneService
participant CPS as ControlPlaneService (proxy)
participant BAT as BackendAvailabilityTracker
participant SI as ConnectPacketInterceptor
participant QSH as QuicSessionHandler
BCM-->>CPS: BACKEND_STATUS ONLINE/OFFLINE heartbeat
CPS->>BAT: markReportedOnline/markReportedOffline
SI->>BAT: status(selectedBackend)
alt selected backend offline
SI->>SI: pick fallback backend if available
end
QSH->>BAT: connect failure -> markUnavailable
QSH->>QSH: connectBackendWithFallback(...)
Key implementation points:
-
Backend status heartbeats and offline burst on stop:
BackendControlPlaneService.start()/stop(). -
Proxy status ingestion and state update:
ControlPlaneService.handleBackendStatus(...). -
Connect-time reroute + connect-time fallback retries:
ConnectPacketInterceptor.resolveBackend(...)andQuicSessionHandler.connectBackendWithFallback(...).
4. Security invariants enforced by this flow
-
Backend token validation is never bypassed; backend validates referral token and context.
-
Proxy and backend control-plane messages are envelope-validated (sender/time/ttl/nonce replay/payload limits).
-
Backend selection for join/transfer is bounded by availability tracker and deterministic fallback logic.
-
Stream bridging starts only after backend channel and handshake path are in a valid state.
title: Operations Runbook
---Operations Runbook
This runbook defines safe operational procedures for proxy/backend deployments in v0.4.0.
Safe Defaults
Keep these defaults unless there is a documented exception:
-
Proxy:
-
security.proxy_secret: strong random value. -
[messaging].enabled:truewhen command/control-plane sync is required. -
[messaging].control_*: keep defaults unless load-testing indicates a required change. -
[rate_limits].handshake_concurrent_max: keep bounded (256default). -
[rate_limits].routing_concurrent_max: keep bounded (256default). -
Backend:
-
enforce_proxy = true. -
require_authenticated_mode = true. -
control_expected_sender_id = "proxy"(or explicit trusted sender id). -
proxy_secretrotation only with overlap window (proxy_secret_previous) and planned rollout.
Startup Sync Procedure
Use this sequence on normal startup:
-
Start proxy with valid config and verify
/healthisREADYorDEGRADED. -
Start backend-mod and verify config loads without validation errors.
-
Verify command registry sync:
-
backend requests snapshot;
-
proxy sends snapshot;
-
backend marks registry synchronized.
-
Validate control-plane channel with a controlled transfer command.
Expected result:
-
No
VERSION_MISMATCH,UNEXPECTED_SENDER,INVALID_TIMESTAMP, orREPLAYED_*spikes in reject counters.
Failure Scenario: Messaging Unavailable
Symptoms:
-
backend command bridge is not synchronized;
-
control-plane transfer path unavailable;
-
messaging channel errors in logs.
Actions:
-
Confirm proxy/ backend messaging bind addresses and ports.
-
Confirm shared secret parity (
proxy_secret) and sender expectations (control_sender_id,control_expected_sender_id). -
Keep backend running with command bridge disabled until messaging is healthy.
-
Restore messaging, trigger snapshot sync again, then re-enable command routing.
Do not:
-
disable
enforce_proxy; -
bypass control-plane validation logic.
Failure Scenario: Registry Desync
Symptoms:
-
backend command execution rejected with unsynchronized registry policy;
-
command set mismatch between proxy and backend.
Actions:
-
Trigger registry snapshot request from backend.
-
Confirm proxy snapshot encode path has no payload-limit reject.
-
Confirm snapshot sender/version/timestamp/replay checks pass on backend.
-
If desync persists, restart backend bridge component first, then proxy messaging component.
Failure Scenario: Version Mismatch
Symptoms:
-
deterministic reject reason
VERSION_MISMATCHin command/control-plane paths.
Actions:
-
Stop rollout immediately (no partial deploy).
-
Align proxy/backend/shared module versions.
-
Re-run compatibility tests before re-enabling traffic.
-
Resume rollout only after mismatch counters remain stable at zero.
Rollback Procedure
Use rollback if production safety guarantees cannot be restored quickly:
-
Stop new deployments.
-
Roll back proxy + backend-mod + shared artifacts as one versioned set.
-
Keep config compatible with rolled-back artifact version.
-
Validate:
-
auth mode requirement,
-
proxy enforcement,
-
control-plane sender/version validation,
-
health endpoint status.
Rollback acceptance:
-
login/transfer/control-plane paths are deterministic and stable under smoke load.
Post-Incident Checklist
After recovery:
-
Save relevant structured logs with correlation ids.
-
Export
/metricsand/statussnapshots for incident window. -
Record root cause, impacted invariants, and remediation PRs.
-
Add or update regression tests for the exact failure mode.
Configuration
Each mod gets its own data folder under mods/<mod-id>/. Use ConfigManager to create or load TOML files in that directory.
val config = context.configManager.config(
name = "settings",
createIfMissing = true
) {
"""
enabled = true
greeting = "hello"
""".trimIndent()
}
Paths are relative to the mod folder. If name has no file extension, .toml is appended automatically.
Examples:
-
settings->mods/<id>/settings.toml -
nested/chat->mods/<id>/nested/chat.toml
Lineage Modding
This documentation covers the public API used to build Lineage mods. The same Markdown files are used for Dokka and GitHub docs to keep them in sync. Some features require explicit capabilities declared in @LineageModInfo.
Contents
-
getting-started.md
-
mod-metadata.md
-
lifecycle.md
-
commands.md
-
events.md
-
players.md
-
messaging.md
-
config.md
-
permissions.md
-
scheduler.md
-
services.md
-
localization-text.md
-
backends.md
-
operations-runbook.md
-
logging-ux.md
-
security-invariants.md
-
proxy-auth-routing-flow.md
Permissions
Use PermissionChecker to evaluate permission strings against a subject.
if (context.permissionChecker.hasPermission(sender, "lineage.example.use")) {
sender.sendMessage("Allowed")
}
CommandSender implements PermissionSubject, so it can be checked directly.
Scheduler
Schedule work on the proxy runtime with Scheduler.
val handle = context.scheduler.runLater(Duration.ofSeconds(5)) {
context.logger.info("Delayed task")
}
Use runSync, runAsync, or runRepeating depending on your needs. Cancel with handle.cancel().
Messaging
Messaging provides UDP channels for proxy and backend communication.
Requires the MESSAGING capability in @LineageModInfo. Channel ids under the lineage. namespace are reserved for internal traffic.
Raw channel:
val channel = context.messaging.registerChannel("mods:hello") { message ->
val text = message.payload.toString(Charsets.UTF_8)
context.logger.info("Got: {}", text)
}
channel.send("hi".toByteArray())
Typed channel:
val typed = MessagingChannels.registerTyped(
context.messaging,
"mods:chat",
Codecs.UTF8_STRING
) { message ->
context.logger.info("Got: {}", message.payload)
}
typed.send("hello")
Lifecycle
LineageMod exposes three lifecycle hooks:
-
onLoad(context): called once after the mod is constructed and theModContextis ready. -
onEnable(): called after all mods are loaded and dependency order is resolved. -
onDisable(): called during shutdown or reload.
Use onLoad for wiring services and onEnable for runtime logic.
title: Logging UX
---Logging UX
This guide defines deterministic log triage for production operations. Goal: diagnose incidents using reason + correlationId + /metrics//status without manual free-form parsing.
Structured Event Shape
Security-critical proxy/backend logs use one stable key/value format:
-
category(handshake,transfer,control-plane,command-gateway,command-registry-sync) -
severity(INFO,WARN,ERROR) -
reason(fixed reject/result reason) -
correlationId(when available) -
extra fields (sorted by key)
Example:
category=control-plane severity=WARN reason=INVALID_TIMESTAMP correlationId=proxy:nonce details=timestamp_window_validation_failed
Correlation Rules
Use this priority order:
-
correlationId(primary key). -
reason+ short timestamp window. -
playerId/session.idfor handshake and transfer incidents.
Correlation sources:
-
handshake:
session.idorplayerId; -
transfer:
TransferRequest.correlationId; -
control-plane: message
correlationIdor fallbacksenderId:nonce.
Fast Triage Workflow
-
Check
health:
curl -s http://127.0.0.1:9091/health
-
Find reject spikes by reason:
curl -s http://127.0.0.1:9091/metrics | rg "lineage_proxy_(handshake_errors|control_reject|routing_decisions)_total"
-
Confirm runtime state:
curl -s http://127.0.0.1:9091/status
-
Trace the top
reasonin logs:
rg "reason=INVALID_TIMESTAMP|reason=UNEXPECTED_SENDER" proxy.log backend.log
-
Trace one
correlationIdacross both sides:
rg "correlationId=proxy:1707433539123" proxy.log backend.log
Reason-to-Action Mapping
-
ALPN_MISMATCH: client/proxy protocol mismatch. Validate client ALPN and server baseline. -
CONNECTION_RATE_LIMIT: source exceeds connection budget. Confirm flood and tune rate limits only after load test. -
HANDSHAKE_INFLIGHT_LIMIT: handshake concurrency cap reached. Scale instances or increase cap in controlled steps. -
INITIAL_ROUTE_DENIED: routing strategy rejected backend selection. Check backend availability and route policy. -
INVALID_TIMESTAMP: clock skew or stale control message. Verify NTP and replay/skew windows. -
UNEXPECTED_SENDER: control message sender id mismatch. Checkcontrol_sender_idandcontrol_expected_sender_id. -
PROXY_TOKEN_REJECTED: token invalid/signature mismatch/replay. Verify secret parity and rollout order. -
VERSION_MISMATCH: mixed artifact versions. Stop partial rollout and alignproxy/backend-mod/shared. -
MALFORMED_SNAPSHOTorREPLAYED_SNAPSHOT: registry sync payload integrity/replay failure. Re-run snapshot sync path. -
TRANSFER_FORWARD_FAILED: transfer request/result delivery issue. Verify messaging channel health and destination backend id.
No-Secret Logging Guard
Expected behavior:
-
any key containing
tokenorsecretis logged as<redacted>; -
raw token payloads are not emitted.
Quick verification:
rg -n "token=|secret=" proxy.log backend.log | rg -v "<redacted>"
If a real secret/token appears in logs:
-
treat it as a security incident;
-
rotate affected secrets immediately;
-
add a regression test for the exact log path before next rollout.
Services
ServiceRegistry lets mods share instances with each other.
val key = ServiceKey(MyService::class.java)
context.serviceRegistry.register(key, MyService())
Retrieve later:
val service = context.serviceRegistry.get(key)
Built-in services
Lineage also publishes built-in services for mods:
-
LocalizationService.SERVICE_KEY -
TextRendererService.SERVICE_KEY -
RoutingStrategy.SERVICE_KEY
Example:
val i18n = context.serviceRegistry.get(LocalizationService.SERVICE_KEY)
val text = context.serviceRegistry.get(TextRendererService.SERVICE_KEY)
val line = i18n?.render(player, "help_header", mapOf("count" to "3"))
val styled = text?.renderForPlayer(player, "<gradient:#ff0000:#00ffcc>Hello</gradient>")
Backends
BackendRegistry exposes the configured backend servers.
for (backend in context.backends.all()) {
context.logger.info("Backend {} -> {}:{}", backend.id, backend.host, backend.port)
}
You can move a player to a backend by id:
player.transferTo("hub-1")
Commands
Commands are registered through CommandRegistry.
class PingCommand : Command {
override val name = "ping"
override val aliases = listOf("pong")
override val description = "Basic connectivity test."
override val usage = "ping"
override val permission: String? = null
override val flags = emptySet<CommandFlag>()
override fun execute(context: CommandContext) {
context.sender.sendMessage("pong")
}
override fun suggest(context: CommandContext): List<String> = emptyList()
}
Register from your mod:
context.commandRegistry.register(PingCommand())
Use CommandContext.hasPermission(...) if you want to handle permission checks manually. CommandSender.type is CONSOLE, PLAYER, or SYSTEM. Permissions are enforced by the proxy. Backend registration is a thin bridge.
Proxy commands are mirrored to backends as native commands:
-
/<namespace>:<command>is always registered. -
/<command>is registered only if the name is not already taken.
The namespace for core commands is lineage. For mod commands it is the mod id. If a conflict exists, only the namespaced command is available.
Supported flags:
-
PLAYER_ONLYblocks non-player senders. -
HIDDENskips registering the non-namespaced form.
Players
PlayerManager exposes online proxy sessions. Each ProxyPlayer represents a connected player and can be moved between backends.
val player = context.players.getByName("Example")
player?.sendMessage("Hello from the proxy")
Useful fields:
-
ProxyPlayer.id -
ProxyPlayer.username -
ProxyPlayer.state -
ProxyPlayer.backendId
Transfer example:
player?.transferTo("hub-1")
Localization and Text
Lineage proxy supports file-based localization and bounded markup rendering for player/system messages.
Message bundles
-
messages/en-us.toml -
messages/ru-ru.toml
Bundles are created automatically on first start and can be edited live.
Language fallback chain:
-
exact locale (for example
ru-ru); -
language family (
ru->ru-ru,en->en-us); -
default
en-us.
Markup renderer
Renderer profiles:
-
game -
console -
plain
Supported syntax:
-
&a,&l,&r(legacy + section equivalents) -
<#RRGGBB>,&#RRGGBB -
<red>...</red>,<bold>...</bold>,<italic>...</italic>,<underline>...</underline> -
<gradient:#ff0000:#00ffcc>text</gradient>
Runtime limits
styles/rendering.toml controls hard limits:
-
max_input_length -
max_nesting_depth -
max_gradient_chars -
max_tag_length
These bounds are enforced to keep rendering deterministic and safe under malformed input.
Mod API services
Use ServiceRegistry:
-
LocalizationService: -
text(language, key, vars) -
render(player, key, vars) -
send(player, key, vars) -
TextRendererService: -
renderForPlayer(player, rawMarkup) -
renderForConsole(rawMarkup) -
renderPlain(rawMarkup)
Reload
Use messages reload from proxy console (or with permission in-game) to reload:
-
messages/*.toml -
styles/rendering.toml