Development
How to work on Conduit without breaking a live network.
Ground rules#
- Verify before you ship. Every change is proven against the running system — panel features through the browser (screenshots, DOM assertions), connector changes through live server logs, infrastructure through the actual nodes. "It compiles" is not done.
- The implementation is the source of truth. Docs describe what exists; if they disagree with code, fix the docs or the code — never leave both.
- Design system is law. Every panel surface follows the Cloudflare school: flat
panels, hairline borders, band layouts, the one brand blue, entrance animations via
the shared motion primitives,
motion-safegating. No drop shadows, no glow.
Components#
Panel (dashboard/)#
cd dashboard
npm install
CONDUIT_CONTROLLER=off npm run dev # UI/API without touching the cluster
npx tsc --noEmit # types
npm run build # production build (Turbopack)- App Router, React 19, Tailwind 4 (OKLCh tokens in
src/app/globals.css). - Shared UI in
src/components(motion primitives incomponents/motion). - All server logic in
src/lib— the reconcile engine (engine.ts), store (store.ts), Proxmox client, firewall renderer, and ~40 focused modules. - Gotcha: cross-route library state must live on
global.__conduit*— Next bundles libs per route, so module-local singletons silently split per bundle.
Connector (plugin/)#
bash plugin/build.sh # plain javac + jar; fetches API jars into plugin/lib onceOne jar serves Paper and Velocity (entry points paper/ConduitPaperPlugin.java,
velocity/ConduitVelocityPlugin.java). Soft-depends (Skript, LuckPerms, voicechat) are
compile-only — never shaded. Deploy to a live fleet via install/update.sh (builds the
panel, snapshots the store, ships bundle + jar into the node stores, verifies every
replica; scripts/upgrade-live.sh remains as a shim). From there a reconcile pass stages
the canonical jar on any instance whose heartbeat reports an older build — activation
still waits for a restart, which stays a human decision; the Plugins page shows exactly
who is pending. The lesson behind that pass: the jar used to reach containers only at
provision time, and the fleet quietly ran three different month-old builds while every
deploy staged a new one.
When a heartbeat field you added doesn't arrive, read ConduitClient's serializer before
theorizing — the player-map serializer once whitelisted three keys and silently dropped
every field added after it, which kept a whole panel feature dead with zero log lines.
Two versions in build.sh are worth understanding before changing them:
JAVA_TARGET(21) is the bytecode level, and because the same jar loads on both platforms it can only be as new as the oldest JRE in the fleet.JAVA_FLOORinlib/provision.tsis the other half of that contract — it stops any service being provisioned below the target. Raise them together, and only once every running instance is on the newer JRE.PAPER_MCis the API compiled against, and it should track what the fleet runs. Mojang renames constants between minor versions, so an older API pins names that no longer exist at runtime — the jar builds fine and then throwsNoSuchFieldErroron first use. Constants known to have moved live inpaper/Compat.java, resolved by name so both the old and new spelling work; anything it can't find is logged at load and the dependent behaviour turns itself off.
GameRule.RANDOM_TICK_SPEED, DO_DAYLIGHT_CYCLE and DO_MOB_SPAWNING (used by the minigame world
setup) are deprecated for removal upstream. They still exist on 1.21.11, so they only warn, but they
will need the Compat treatment or a registry lookup whenever Paper actually drops them.
Software built from source (velocity-source, builder)#
Some software has no upstream build you can use — a patched proxy, most obviously. A blueprint can
declare software.source = { type:"git", repo, ref, buildCmd, artifact } instead of relying on a
vendor feed, and the panel then tracks it like any other version:
| vendor feed | source build |
|---|---|
version line (1.21.11) | git ref (dev/3.0.0) |
build number (794) | commit (843a47e) |
| newer build exists | the ref moved |
| auto-hotfix | auto-rebuild + roll |
| pin | stay on this commit |
Two things differ from a vendor feed, both deliberate:
- Builds compare by identity, not order. Vendor build numbers only increase, so
latest > installedis safe. A ref can be force-pushed or reverted backwards and that is still a change worth rolling out, so the test islatestSha !== installedSha. - Artifacts are content-addressed by commit (
conduit/builds/<name>/<sha>.jarin object storage). Rebuilding an unmoved ref is free, rollback is picking an older sha, and the sha recorded on the task answers "what is actually running" long after the fact.
Compiling happens on a build server — an ordinary task from the builder blueprint (generic
kind, git + JDK, no start command, idle between builds). It is not done on the panel: a gradle build
wants a JDK and a core for minutes, and neither belongs in the control plane. The panel resolves refs
over plain HTTPS using git's smart-HTTP ref advertisement (no git binary, no clone), then hands the
builder a presigned PUT for exactly one object — so the builder holds no bucket credentials, and
provisioning pulls the jar with a presigned GET. (The in-game map studio uploads worlds the same
way: the connector asks the panel, gets a presigned PUT for exactly one tarball, and the panel
verifies the object exists before registering the map — no S3 credentials ever reach a game server.)
Because it is a fork, the honest cost stands: re-patch per upstream release, re-validate per protocol
bump, on the component every player connects through. Keep it per-task, prove it on a test proxy, and
remember the stock jar is one PATCH away (drop source and the Fill resolver returns).
Agents (agent/)#
Two of them. agent/src/index.mjs is the node agent: plain Node ≥ 18, no build step,
shipped by scripts/deploy-agent.sh, systemd unit conduit-agent. Bump its VERSION
constant when the protocol changes. Also here: console-proxy.mjs (the panel CTs' terminal
bridge) and cli-gateway.sh (the SSH gateway's forced command).
agent/ctagent/ is the container agent — single-file Python 3, standard library only, no
build step and no dependencies to install. It travels inside the panel bundle (see
install/modules/bundle.sh) and a reconcile pass pushes it into containers, so shipping a
change means bumping VERSION in the .py and CTAGENT_VERSION in
dashboard/src/lib/ctagent.ts to match. They are compared on every probe: a mismatch makes the
panel treat that agent as absent and fall back to pct, which is what makes a rollout safe
mid-flight — and it is also what upgrades the fleet, since a skewed agent is reinstalled.
CLI (cli/)#
Zero-dependency Node ESM; no build step. cli/conduit is a CJS-safe bootstrap (the
panel replicas run Node 20, which parses extensionless files as CommonJS) that hands to
cli/main.mjs; commands are auto-discovered from cli/commands/*.mjs — drop a file in,
default-export { name, summary, usage, run }, done. The panel bundle ships the whole
directory for the SSH gateway.
Installer (install/)#
Plain bash, bash -n clean. Entry points share lib/ (ui, core, detect, checks) and
modules/ wrap the proven scripts/; topology comes from install/cluster.conf
(gitignored — cluster.conf.example is the template). install/update.sh is the
standard deploy path and doubles as the panel's TypeScript gate.
Skript content (skript/)#
Ported game content and the glue that bridges it to the connector's Skript API. Start
with skript/conduit-games/PORTING-GUIDE.md.
Six rules about silence#
Every one of these came from a fault that ran for weeks or months in production without anything reporting a problem. They are here because the code enforcing them is spread across a dozen files, and the reasoning is worth more than any single guard.
A swallowed read must not become a recorded verdict. await thing().catch(() => [])
stops one failing call taking a whole page down, and is right more often than not — the
empty array it produces is also indistinguishable from a genuine empty answer. That is
fine where the result is rendered and forgotten. It is not fine where something writes the
result down. backupStorages() had always failed; the restore drills caught it, found no
archive for any container, and recorded "no vzdump backup exists yet" — a verdict
deliberately classified as benign. The thing whose only job is proving backups restorable
was blind for three months and its blindness was filed as health. Use lib/readable where
a read feeds a verdict, and return an outcome that records nothing when you could not look.
A derived view may decide what is shown; it must never hide or delete what somebody wrote. Deriving the per-kind overlay list from the running fleet is the obvious design and it is wrong: delete the last Postgres server and a whole layer of configuration vanishes from the UI while the files sit on disk, ready to apply again. Derive as a union of live state and what already exists.
A verdict type needs a state for "I cannot tell". With only good/bad to choose from,
uncertainty gets encoded as one of them, and it is always the benign one. Coverage taints
its reasons when S3 is unreachable; the drills have unasked; the secrets register shows
"never recorded" rather than inventing a date.
Plant a known-bad before trusting a green check. Used about a dozen times in one day and
it caught its own detector being wrong four times — a regex anchored to line starts that
missed every inline field, a 400-character window that truncated before the evidence, a
toContain that matched its own doc comment, and an ordering check comparing positions in
an import line. A check that has never been watched to fail is worth nothing.
A grep negative is not evidence of absence. serviceDir looked absent because the
copy was private in another file; a whole secrets inventory looked absent because it lived
under a name the search did not cover. Two independent sources beat one clever query.
Refusals are not failures. A 403 for a role that may not do something, or a 404 for a name that does not exist, is the endpoint working. Counting those as failures produces an alert that fires during normal use, and an alert that fires during normal use is one people learn to close.
Testing#
cd dashboard
npm test # vitest — the invariant suite, ~1sThe suite covers logic where a regression would be quiet and expensive: the store's
compare-and-swap under a two-instance race (vi.resetModules() + a globalThis-backed
store gives two "replicas" in one process), the offsite crypto truncation matrix, the
VIP chooser's stickiness and the fast-failover swap planner, backup coverage verdicts,
byte-capped tails, the drift normaliser, node-maintenance window semantics, the
secret-parity extraction (which runs the real shell pipeline through bash rather than a
TypeScript copy that would drift).
A few tests assert things about the source rather than about behaviour, because the bug they guard against has no failing behaviour to catch — it produces silence:
agent-deadlines.test.ts— nofetchin the agent client without a deadline. A hang raises nothing, logs nothing and returns never; there is no assertion that fails, only a control loop that stops. Four calls had no timeout, two of them the onesgetDB()uses, which is how a panel answered/api/healthin 0.7 ms with an eleven-minute-old reconcile tick.reachable-flags.test.ts— every optional boolean onTaskthatengine.tsbranches on must be assigned by some route. Twice in one day a feature was gated on a field nothing could write:/api/networksilently droppedgeyserTargetswhile answering{"ok":true}, anddrainOnUpdatewas read by the reconcile loop and named in its own log message with no route anywhere. Every unit was correct; the seam between them was not.
A detector that has never failed is a detector whose pass means nothing. Both of the
above carry a planted-failure case that feeds them a deliberately broken input and asserts
they notice. That is not ceremony: the plant in reachable-flags immediately caught that
t.replay === true matched the "is this assigned" regex through the first = of ===, so
a flag that was only ever compared looked wired up.
Write new invariants as *.test.ts beside the lib they test — not as throwaway
harnesses. CI (GitHub Actions) runs typecheck + the suite on every push to both
branches. The suite complements, not replaces, the verification culture above: live
behavior is still proven live.
Conventions#
- Commits: plain human prose. No AI-styled attributions ("user asked…"), no
all-caps emphasis, no co-author trailers. Subjects in the conventional
area: what changedshape where it helps. - Branches: work lands on
master;feat/conduit-ha-fleet-platformmirrors it. Direct pushes — no pull requests. - Comments: explain why, at the density the surrounding file already uses. Write
them as prose, not emphasis — no shouted words (
NEVER,CRITICAL,ONLY) and noNOTE:/IMPORTANT:labels; if a point matters, say why it matters. Acronyms (API, UDP, RESP) and literals (WRONGPASS) stay uppercase, obviously. Skip comments that only restate the line below them. - Docs: user-visible behavior changes update
FEATURES.mdand the relevantdocs/page in the same commit.