kanade-agent 0.57.0

Windows-side resident daemon for the kanade endpoint-management system. Subscribes to commands.* over NATS, runs scripts, publishes WMI inventory + heartbeats, watches for self-updates
kanade-agent-0.57.0 is not a library.

CI codecov crates.io docs License: MIT

πŸ“š Docs site: https://kanadehq.github.io/kanade/

ε₯ β€” orchestrate. A self-hosted Rust pub/sub backbone for managing thousands of Windows endpoints without Active Directory. NATS / JetStream carries inventory polling, fleet-wide rollouts, and ad-hoc emergency commands on a single channel.

Status: 0.1.0 β€” Sprint 4 shipped. Agent + backend (axum + SQLite projector + JetStream KV watcher + cron scheduler) + admin CLI + an embedded SPA dashboard + JWT-gated /api/* + agent self-update via the JetStream Object Store. Full design lives in docs/SPEC.md (Japanese, ~1150 lines covering Part 1 overview and Part 2 detailed design).

Why

The off-the-shelf endpoint managers (Intune, Tanium, Workspace ONE, …) either require Active Directory, lock you into a vendor cloud, or both. For shops that want AD-independent, on-prem, scriptable fleet control the answer has historically been "build something on top of a message broker" β€” which everyone reinvents from scratch.

kanade aims to be the reusable shape of that build:

  • NATS + JetStream as the only moving part. Agents speak to the broker over outbound TLS; the broker fans out commands, fans in inventory and results. No AD, no client-pull-from-server, no opening inbound ports on user PCs.
  • Declarative job manifests in Git. Review, history, rollback all come for free; the YAML schema (jobs/*.yaml) is the same input whether you kanade exec ad-hoc or wire it onto a cron kanade schedule.
  • Three layers of stop-the-bleed. Stream max-msgs-per-subject replaces stale rollouts in the broker; consumer-side version checks guard execution; kanade kill <job_id> terminates running children. The emergency-stop path is wired from MVP, not bolted on later (see SPEC.md Β§2.6).
  • Phased build-out. One server is enough for a few hundred endpoints; the same code scales to a 3-node NATS cluster + replicated backend + Postgres for several thousand.

Crates

crate kind role
kanade-shared lib wire types (Command / ExecResult / Heartbeat / HwInventory), NATS subject + KV helpers, YAML manifest schema, teravars-backed config loader
kanade-agent bin Windows-side resident daemon: subscribes to commands.*, runs child processes, publishes results + heartbeats + WMI inventory; watches the layered agent_config + agent_groups KV buckets and reacts live to cadence / membership / target_version changes
kanade-backend bin axum HTTP server: /health, /api/{agents,results,audit,deploy,schedules,config,…}, embedded SPA at /. Auto-bootstraps every required JetStream resource at startup, runs durable projectors (INVENTORY/RESULTS/AUDIT β†’ SQLite) and a tokio-cron-scheduler driven by the schedules KV
kanade bin operator-side admin CLI (kubectl-style single entry point); subcommands talk to NATS directly for run/kill/jetstream and to the backend over HTTP for agent/ping/revoke/config/deploy/schedule

Install

You'll need:

  • Rust 1.85+ (the workspace pins edition = "2024")
  • A NATS server (Go binary, ~15 MB)
# 1. NATS server
scoop install nats-server         # or: winget install nats-io.nats-server

# 2. The three kanade binaries β€” straight from crates.io.
cargo install kanade kanade-agent kanade-backend

kanade, kanade-agent, and kanade-backend are now on your PATH (under ~/.cargo/bin/).

You'll also want the sample configs (configs/agent.toml / configs/backend.toml) and the example manifests (jobs/*.yaml). The fastest way is a shallow clone of this repo:

git clone --depth=1 https://github.com/kanadehq/kanade.git
cd kanade

(or curl the individual files from https://raw.githubusercontent.com/kanadehq/kanade/main/... into your own working dir if you'd rather not clone).

Build it yourself from source. Skip the cargo install step, git clone the full repo, and run cargo install --path crates/kanade --path crates/kanade-agent --path crates/kanade-backend (one --path at a time, or repeat the command three times). That path matters if you're hacking on the crates.

Quick start (4 terminals, ~2 minutes)

Run each step in its own PowerShell window so the daemons stay up. All of them assume cd into the repo root (which holds configs/agent.toml / configs/backend.toml / jobs/).

1 β€” start NATS

nats-server -js -p 4222

2 β€” start the backend

$env:KANADE_AUTH_DISABLE = "1"   # JWT off for development
kanade-backend

The first time it starts against a broker it creates every stream, KV bucket and Object Store itself, so a fresh NATS server + kanade-backend is enough to get a working fleet (kanade jetstream status shows what exists). If a stream has drifted or is corrupted so the backend cannot start, stop it and use the nats CLI with an administrative credential (not kanade) via the operator scripts, then start it again:

./scripts/ops/jetstream-delete.ps1 -Kind stream -Name RESULTS -Server nats://127.0.0.1:4222 -Creds ./admin.creds
./scripts/ops/jetstream-reset.ps1 -Server nats://127.0.0.1:4222 -Creds ./admin.creds        # dry run
./scripts/ops/jetstream-reset.ps1 -Server nats://127.0.0.1:4222 -Creds ./admin.creds -Yes   # wipe all kanade resources

The backend serves the dashboard at http://127.0.0.1:8080 and the JSON API at /api/*. SQLite is created at ./backend.db. Both projectors and the cron scheduler start in the background.

3 β€” start the agent

kanade-agent

Loads ./configs/agent.toml, picks $env:COMPUTERNAME as pc_id, subscribes to commands.all + commands.pc.{pc_id}, then spawns the config_supervisor (watches agent_config + agent_groups KV) plus the heartbeat / inventory / self-update / groups-manager loops. Group membership and cadence settings are read from the KV buckets β€” see kanade group (via the backend API) and kanade config to drive them.

4 β€” drive it

# Round-trip a script via NATS, request/reply.
kanade run $env:COMPUTERNAME -- 'echo hello from kanade'

# Or via the backend's YAML deploy path (writes a row to deployments,
# emits an audit event, broadcasts the Command).
kanade exec jobs/echo-test.yaml

# Liveness probe (the backend asks the agent; needs KANADE_AUTH_TOKEN).
kanade ping $env:COMPUTERNAME

# Inspect via curl…
curl http://127.0.0.1:8080/api/agents
curl http://127.0.0.1:8080/api/results
curl http://127.0.0.1:8080/api/audit

# …or open the dashboard.
start http://127.0.0.1:8080

CLI cheat sheet

kanade run    <pc_id> -- <script>                # request/reply via NATS
kanade ping   <pc_id> [--wait N]                 # ask the agent for a fresh heartbeat (via backend API)
kanade kill   <job_id>                           # publish kill.{job_id}
kanade revoke <cmd_id>                           # script_status = REVOKED (via backend API)
kanade unrevoke <cmd_id>                         # β†’ ACTIVE (via backend API)

kanade jetstream status                          # health snapshot (via backend API)

kanade job create   <path...>                    # upsert into the jobs catalog; accepts files / dirs / globs (configs/jobs/*.yaml)
kanade job export <id> [--out-dir <dir>]         # dump registered YAML to stdout (or <dir>/<id>.yaml)
kanade job export --all --out-dir <dir>          # dump every registered job to <dir>/<id>.yaml
kanade job list                                  # every registered job
kanade job delete <id>                           # refuses if any schedule references it

kanade exec     <job-id>                         # fire a registered job ad-hoc (POST /api/exec/<id>)

kanade app    publish <name> <file> [--version V] # upload an installer to the app-packages store (via backend API; operator role)
kanade app    list | delete <name> <version>     # (via backend API)
kanade script publish <name> <version> <file>    # upload a manifest script body, 4 MB max (via backend API; operator role)
kanade script list | delete <name> <version>     # (via backend API)

kanade schedule create <path...>                 # cron yaml { id, cron, job_id, enabled }; accepts files / dirs / globs
kanade schedule export <id> [--out-dir <dir>]    # dump registered YAML to stdout (or <dir>/<id>.yaml)
kanade schedule export --all --out-dir <dir>     # dump every registered schedule
kanade schedule list
kanade schedule delete <id>

# `kanade agent …` goes through the backend HTTP API (KANADE_AUTH_TOKEN; operator role to publish
# or roll out) β€” no NATS connection or broker token needed. `publish` is capped by the backend at
# 64 MB for the whole upload.
kanade agent publish <binary> [--version <v>]    # upload binary to Object Store (no KV touch)
kanade agent rollout <v> --global  [--jitter <d>]            # fleet-wide
kanade agent rollout <v> --group <name> [--jitter <d>]       # canary / wave
kanade agent rollout <v> --pc    <pc_id> [--jitter <d>]      # single-host pin
kanade agent current                             # read agent_config.global.target_version (global scope only)
kanade agent logs <pc_id> [--tail <n>]           # tail an online agent's log file (via the backend)

# `kanade group …` goes through the backend HTTP API (KANADE_AUTH_TOKEN; operator role to change
# membership) β€” no NATS connection or broker token needed.
kanade group list                                # fleet-wide: every known group + member count + config flag
kanade group list --pc <pc_id>                   # one PC's memberships
kanade group members <name>                      # PCs in this group
kanade group add  <pc_id> <name>                 # add membership (idempotent)
kanade group rm   <pc_id> <name>                 # drop membership
kanade group set  <pc_id> <name> ...             # replace whole list

# `kanade config …` goes through the backend HTTP API (KANADE_AUTH_TOKEN; operator role to change
# anything) β€” no NATS connection or broker token needed. Automation that calls it authenticates
# with an auth token like the other HTTP subcommands.
kanade config get  [--group <name>|--pc <pc_id>] # ConfigScope at this scope (default: global)
kanade config set  <field>=<value> [...]         # set one field (target_version / inventory_* / heartbeat_*)
kanade config unset <field> [...]                # clear one field
kanade config clear [--group <name>|--pc <pc_id>] # delete the whole scope row
kanade config effective <pc_id>                  # resolved view for a PC (built-in -> global -> groups -> pc)

# `kanade meta …` (per-PC operator key/value metadata) goes through the backend HTTP API
# (KANADE_AUTH_TOKEN; operator role to change anything) β€” no NATS connection or broker token
# needed, and the backend must be up. A directory-sync job that calls `kanade meta set` from the
# backend host authenticates with an auth token too. `set` / `rm` touch only their own key (the
# backend does the compare-and-swap), so they never drop keys written concurrently.
kanade meta get   <pc_id>                        # all attributes
kanade meta set   <pc_id> <key> <value>          # upsert one key (empty value keeps the key, blank)
kanade meta rm    <pc_id> <key>                  # drop one key (idempotent)
kanade meta clear <pc_id>                        # drop every key

kanade <subcommand> --help for argument details.

Authoring jobs

YAML manifests in jobs/*.yaml (see spec Β§2.4.1). Sample manifests in the repo cover:

  • jobs/echo-test.yaml β€” minimal ad-hoc command
  • jobs/wave-test.yaml β€” rollout.waves rollout (canary β†’ wave1 with delay)
  • jobs/schedule-test.yaml β€” cron-driven echo every 10 s

A wave manifest sketch:

id: cleanup-disk-temp
version: 1.0.1
target:
  pcs: [PC1234]
execute:
  shell: powershell
  script: |
    $temp = [System.IO.Path]::GetTempPath()
    Remove-Item "$temp\*" -Recurse -Force -ErrorAction SilentlyContinue
  timeout: 600s
  jitter: 5m
rollout:
  strategy: wave
  waves:
    - { group: canary, delay: 0s  }
    - { group: wave1,  delay: 30m }

Config files

Both use teravars templating β€” {{ system.host }}, {{ env(name="X", default="Y") }}, {% if is_windows() %}…{% endif %} are all available.

agent.toml (intentionally minimal β€” fleet policy lives in the agent_config + agent_groups KV buckets, edited via kanade config / kanade agent groups):

[agent]
id = '{{ system.host }}'
nats_url = 'nats://127.0.0.1:4222'

[log]
path = 'logs/agent.log'
level = 'info'

Older agent.toml files that still carry [agent] groups = […] or an [inventory] section keep loading β€” both fields are parsed via #[serde(default)] β€” but the values are logged-and-ignored at startup. Removal is scheduled for v0.4.0.

backend.toml:

[server]
bind = '0.0.0.0:8080'

[nats]
url = 'nats://127.0.0.1:4222'

[db]
sqlite_path = './backend.db'

[log]
path = 'logs/backend.log'
level = 'info'

Authentication & RBAC

/api/* is protected by a single middleware (crates/kanade-backend/src/auth.rs). A request is admitted by the first of these that matches:

Mode Selector Use for
open KANADE_AUTH_DISABLE=1 local dev, cargo run (synthesises an admin)
service token StaticToken registry value or $KANADE_AUTH_STATIC_TOKEN CI / non-interactive automation β€” admin-equivalent, no account row
account JWT username + password β†’ POST /api/auth/login mints an HS256 token the normal path for humans; carries the caller's role

A non-matching bearer falls through from the service token to JWT validation, so a service token and per-user JWTs coexist. With none of the three set the backend falls back to a hard-coded dev secret and logs a loud warning β€” fine for one-shot debugging, never for production.

Roles

Accounts live in the SQLite users table (argon2id password hashes). Each account has one hierarchical role β€” admin βŠ‡ operator βŠ‡ viewer:

Role Can
viewer read-only β€” every GET /api/* (dashboards, inventory, logs, audit)
operator viewer + fleet mutations (exec / kill / schedules / jobs / config / releases / uploads)
admin operator + account management (create / role / disable / delete)

Enforcement is server-side (route_layer guards reject 403); the SPA also hides controls above the caller's role. Role and disabled are re-read from the DB on every request, so disabling an account or changing its role takes effect immediately β€” existing tokens don't stay valid until expiry.

Secrets

JwtSecret signs and verifies the minted tokens; it's the fleet-wide skeleton key, so it lives only on the backend host. Each secret resolves registry-first, env-second:

StaticToken:           HKLM\SOFTWARE\kanade\backend\StaticToken            β†’  $KANADE_AUTH_STATIC_TOKEN
JwtSecret:             HKLM\SOFTWARE\kanade\backend\JwtSecret              β†’  $KANADE_JWT_SECRET
BootstrapAdminPassword:HKLM\SOFTWARE\kanade\backend\BootstrapAdminPassword β†’  $KANADE_BOOTSTRAP_ADMIN_PASSWORD

Provision the registry values with deploy-backend.ps1 so the script can strip non-admin ACEs from the key (SYSTEM + Administrators read only). The env vars stay for cargo run / cargo make dev / non-Windows hosts. KANADE_AUTH_DISABLE stays env-only β€” it's a presence flag, not a secret.

Note: the registry values are stored as plaintext REG_SZ protected by a SYSTEM + Administrators ACL. That defends against non-admin users but not against offline disk / hive-backup theft; DPAPI / external-vault hardening is a tracked follow-up.

Bootstrapping the first admin

On startup, if the users table is empty the backend seeds a single admin from BootstrapAdminPassword (registry) / $KANADE_BOOTSTRAP_ADMIN_PASSWORD (username defaults to admin, override with $KANADE_BOOTSTRAP_ADMIN_USER). The seeded account is flagged must-change-password. If no bootstrap password is configured, no admin is seeded (a warning is logged) β€” set one and restart, or fall back to the static service token.

Clients

Clients send Authorization: Bearer <token> on every /api/* request:

  • SPA: sign in with username + password on the login page; the token is kept in localStorage. Role is shown in the sidebar; admins get an Accounts page. A 401 auto-clears the token and re-prompts.
  • CLI: kanade login exchanges credentials for a token; export it as $env:KANADE_AUTH_TOKEN for subsequent commands. kanade account … (admin) manages users.
# Backend side β€” production (registry, hardened ACL)
.\deploy-backend.ps1 -JwtSecret 'sign-key-2026' -BootstrapAdminPassword 'change-me-now'

# Operator side (CLI) β€” log in, then run fleet commands
$env:KANADE_AUTH_TOKEN = (kanade login --username admin --token-only)
kanade account create alice --role operator      # admin only
kanade exec jobs\echo-test.yaml                  # operator+

# CI / automation β€” admin-equivalent service token, no login round-trip
$env:KANADE_AUTH_TOKEN = "<your-fleet-token>"   # matches backend StaticToken

NATS authentication

Separate from the backend HTTP layer above. By default nats-server -js listens on :4222 without auth β€” anyone on the LAN who can reach the broker can publish commands.pc.<host> and execute scripts on every agent. Lock it down for production with token auth:

  1. Start nats-server with the bundled config:

    nats-server -c configs/nats-server.conf
    

    The shipped configs/nats-server.conf enables JetStream + an authorization.token block. Pick your own secret. For production, run as a Windows service via deploy-nats.ps1 (the script applies a SYSTEM + Administrators-only ACL on the installed config so the token isn't readable by other users).

    Generate your own token β€” never ship the sample. The value in configs/nats-server.conf is a placeholder committed to a public repository, so a fleet deployed with it has a credential anyone can read. Mint one per fleet, e.g. [Convert]::ToBase64String((1..32|%{Get-Random -Max 256})).

  2. Provision the token on every kanade host. The shared kanade_shared::nats_client::connect() helper takes the caller's role (agent / backend / cli) and resolves the token in this order:

    (1) HKLM\SOFTWARE\kanade\<role>\NatsToken (REG_SZ) β€” production. deploy-agent.ps1 / deploy-backend.ps1 accept -NatsToken and write the value with a hardened ACL (SYSTEM + Administrators only). Low-privilege users on the host cannot read it back, which a Machine-scope environment variable cannot prevent.

    (2) HKLM\SOFTWARE\kanade\agent\NatsToken β€” the shared fallback. Before roles existed every binary read this one path, so it stays as a fallback and an unmigrated fleet keeps working unchanged. Splitting the credentials is what lets the broker tell the roles apart, which is a prerequisite for authorization { users: [...] } subject permissions β€” see #1155 for why one fleet-wide token is worth moving off.

    # On agent + backend hosts
    .\deploy-agent.ps1   -NatsToken '<your-fleet-token>'
    .\deploy-backend.ps1 -NatsToken '<your-fleet-token>'
    

    (3) $KANADE_NATS_TOKEN environment variable β€” dev / fallback. Used only when neither registry value is present. Service binaries run as LocalSystem and never see user-session env vars, so this branch fires for cargo run, the operator CLI, and cargo make dev:

    $env:KANADE_NATS_TOKEN = '<your-fleet-token>'
    kanade run <pc_id> -- hostname
    

    (3) No token β†’ unauthenticated connect. Works against a broker started without authorization { ... } β€” fine for local dev.

nats_url in agent.toml / backend.toml stays plain. The secret never lands in config files or process listings.

Seeing which credential each host actually used

Provisioning a credential and a host presenting it are different facts, and only the second one is worth acting on. The backend polls the broker's monitoring endpoint (/connz) every 60 s and records, per host, the NATS user its live connection authenticated as β€” reported by NATS, not by the agent, because a host kitted from a stale image is exactly the one whose own account of itself cannot be trusted (#1270). It surfaces on GET /api/agents as nats_user, with nats_user_since marking when that last changed:

value meaning
(absent) never correlated β€” no live connection seen, or the agent predates #1270 and announces no pc_id. Unknown, not "old credential".
shared-token the single fleet-wide token. The migration queue.
no-auth the broker authenticated nobody (no authorization block).
unknown connected, credential unnameable without printing a secret.
anything else the NATS username, verbatim.
# How many hosts are still on the shared token?
(irm http://<backend>/api/agents -Headers $h) |
  Group-Object nats_user | Select-Object Count, Name

Under today's token auth every host that correlates lands in the one shared-token bucket β€” hosts whose agent announces no pc_id stay absent, and a connection the backend cannot vouch for reads unknown. That is the endpoint's answer rather than a shortcut: nats-server reports which credential a connection authenticated with, and for token auth it reports only that a token was used ([REDACTED], measured on 2.14.3). A users split is what makes the answer per-role β€” and the value is never stored raw regardless, because whether the broker hides a credential is the broker build's choice, not this code's.

Treat the monitoring port as sensitive: it enumerates every connection, its IP and its subscriptions, and NATS provides no authentication for it. Both shipped broker configs bind it to loopback (http: "127.0.0.1:8222"), which is right for the usual deploy where the backend runs on the broker host β€” note that the bare http_port: 8222 form listens on all interfaces. If you split backend and broker across hosts, widen the bind to a management interface and set monitor_url to match.

Set [nats] monitor_url in backend.toml if monitoring does not live at http://<nats-host>:8222. If it is unreachable or switched off, the projection simply stops updating: values already recorded are kept (they remain the best answer about each host), and a host that was never correlated stays empty until the first successful poll.

For multi-tenant / per-agent identity (NKeys, NATS JWT, mTLS), see spec Β§2.7.1. Stick with the shared token while operating ≀ ~1000 hosts.

Dev workflow

cargo make check       # fmt-check + clippy + test + lock-check (same as CI)
cargo make fmt         # apply formatting
cargo make on-add      # renri post_create hook (apm install + vcs fetch)

The workspace pins [profile.dev] debug = "line-tables-only" because Windows MSVC link.exe hits LNK1318 (PDB record limit) once axum + sqlx + reqwest + tokio-cron-scheduler + jsonwebtoken all sit in one workspace; line-tables-only keeps backtraces useful without exploding the PDB.

Integration tests (kanade-agent)

crates/kanade-agent/tests/offline_boot.rs exercises the offline- tolerant boot path from #137 end-to-end by spawning a real nats-server and the agent binary. The tests are #[ignore]-gated so cargo test (and cargo make check) stays fast for local dev without needing nats-server on every machine. The dedicated Integration workflow installs nats-server per runner (Linux / macOS / Windows) and opts in via --ignored.

To run them locally, install nats-server first:

Platform Command
Windows scoop install nats-server
macOS brew install nats-server
Linux GitHub release, or your distro's package manager

Then:

cargo test -p kanade-agent --test offline_boot -- --ignored --nocapture

Total runtime is ~30 s. Each test spawns its own broker on a free port and a fresh agent process with a temp agent.toml, asserts either "agent stays alive without broker" or "agent's heartbeat arrives within timeout" depending on the case, and tears everything down on Drop. If nats-server isn't on PATH, each test logs a clear "skipping" message instead of panicking.

Production install layout

cargo install drops the binaries under ~/.cargo/bin/ (user-local). For a real deployment, copy them into the spec Β§2.11 layout and register a service so they survive reboots.

Path layout

Windows                                    Linux
C:\Program Files\Kanade\                   /usr/local/bin/
  β”œβ”€β”€ kanade-agent.exe                       β”œβ”€β”€ kanade-agent
  β”œβ”€β”€ kanade-backend.exe                     β”œβ”€β”€ kanade-backend
  β”œβ”€β”€ kanade.exe                             β”œβ”€β”€ kanade
  └── nats-server.exe                        └── nats-server

C:\ProgramData\Kanade\config\              /etc/kanade/
  β”œβ”€β”€ agent.toml                             β”œβ”€β”€ agent.toml
  β”œβ”€β”€ backend.toml                           β”œβ”€β”€ backend.toml
  └── nats-server.conf  (hardened ACL)       └── nats-server.conf

C:\ProgramData\Kanade\data\                /var/lib/kanade/
  β”œβ”€β”€ state.db        (agent)                β”œβ”€β”€ state.db
  β”œβ”€β”€ outbox\         (agent)                β”œβ”€β”€ outbox/
  β”œβ”€β”€ staging\        (self-update)          β”œβ”€β”€ staging/
  β”œβ”€β”€ backend.db      (backend)              β”œβ”€β”€ backend.db
  β”œβ”€β”€ certs\                                 β”œβ”€β”€ certs/
  └── nats\           (JetStream data)       └── nats/

C:\ProgramData\Kanade\logs\                /var/log/kanade/
  β”œβ”€β”€ agent.log                              β”œβ”€β”€ agent.log
  β”œβ”€β”€ backend.log                            β”œβ”€β”€ backend.log
  └── nats-server.log                        └── nats-server.log

Config discovery

Every binary looks up its config file in this exact order (no cwd fallback β€” too easy to load the wrong file by accident):

  1. --config <path> CLI flag (always honored, even if the file doesn't exist β€” that's the caller's choice).
  2. Environment variable: KANADE_AGENT_CONFIG for kanade-agent, KANADE_BACKEND_CONFIG for kanade-backend. Non-empty value wins.
  3. <config_dir>/<basename>:
    • Windows: %ProgramData%\Kanade\config\agent.toml
    • Linux: /etc/kanade/agent.toml

If none of the three is reachable, the binary exits with a message listing every option an operator can use to fix it.

Install scripts (Windows, recommended)

PowerShell scripts under scripts/ handle the whole "drop a folder onto the target, run as Admin" path β€” no Rust toolchain, no bun, no git required on the deploy host:

# 1. On any Windows box (no dev tooling needed): pull pre-built
#    binaries straight from GitHub Releases via Invoke-WebRequest
#    and assemble one stage folder per role under .\dist\.
PS> .\scripts\build-release.ps1
# β†’ dist\agent\, dist\backend\, dist\nats\
#   each contains: <role>.exe, <role>.{toml,conf}, deploy-<role>.ps1
#
# Variants:
#   -Roles agent,backend           # skip nats
#   -NatsVersion 2.11.10           # pin a specific NATS broker tag
#   -FromSource                    # compile from this checkout (cargo + bun required)
#   -FromCrates                    # install from crates.io (cargo required)
#   -Zip                           # also produce dist\<role>.zip

# 2. Copy each stage folder onto the target host (xcopy, robocopy,
#    scp, USB stick β€” whatever fits your environment).

# 3. On the target host, run the matching script as Administrator:
#    (<your-fleet-token> is one you generated β€” see "NATS authentication";
#     the value in configs/nats-server.conf is a public placeholder.)
PS> .\deploy-nats.ps1     -NatsToken '<your-fleet-token>'    # broker host (run once)
PS> .\deploy-agent.ps1    -NatsToken '<your-fleet-token>'    # every endpoint
PS> .\deploy-backend.ps1  -NatsToken '<your-fleet-token>' `
                          -StaticToken '<api-token>'                # admin box

Re-running the script upgrades the binary in place and preserves the edited config. Pass -ForceConfig to overwrite the installed config from the source folder, or -NoStart to skip the post-install service start.

Windows Service registration (sc.exe)

If you'd rather not use the deploy scripts (or want to understand exactly what they do), here are the equivalent manual commands:

# Stage the binaries
New-Item -ItemType Directory -Force 'C:\Program Files\Kanade'
Copy-Item "$env:USERPROFILE\.cargo\bin\kanade-agent.exe"   'C:\Program Files\Kanade\'
Copy-Item "$env:USERPROFILE\.cargo\bin\kanade-backend.exe" 'C:\Program Files\Kanade\'

# Stage the config (review + edit first)
New-Item -ItemType Directory -Force 'C:\ProgramData\Kanade\config'
Copy-Item .\configs\agent.toml   'C:\ProgramData\Kanade\config\'
Copy-Item .\configs\backend.toml 'C:\ProgramData\Kanade\config\'

# Register the agent as a service running under LocalSystem.
sc.exe create KanadeAgent `
  binPath= '"C:\Program Files\Kanade\kanade-agent.exe"' `
  start= auto `
  obj= LocalSystem `
  DisplayName= "Kanade Endpoint Agent"
sc.exe failure KanadeAgent reset= 86400 actions= restart/60000/restart/60000/restart/60000

# Register the backend the same way.
sc.exe create KanadeBackend `
  binPath= '"C:\Program Files\Kanade\kanade-backend.exe"' `
  start= auto `
  obj= LocalSystem `
  DisplayName= "Kanade Backend"

sc.exe start KanadeAgent
sc.exe start KanadeBackend

Linux systemd units

# /etc/systemd/system/kanade-backend.service
[Unit]
Description=Kanade Backend
After=network.target nats.service

[Service]
ExecStart=/usr/local/bin/kanade-backend
Restart=always
User=kanade
Environment=RUST_LOG=info

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now kanade-backend.service

The agent unit is symmetric (kanade-agent.service, ExecStart=/usr/local/bin/kanade-agent).

Scaffolded with kata

The skeleton (AGENTS.md / Makefile.toml / clippy.toml / rustfmt.toml / .github/workflows/* / etc.) was applied via github.com/yukimemi/pj-presets:rust-cli through kata init. The Cargo workspace layout under crates/ is hand-written because the preset is single-crate by default; a pj-rust-workspace layer is on the future TODO once the multi-crate patterns stabilise.

License

MIT β€” see LICENSE.

The shipped binaries statically link their Rust dependencies, and kanade-backend embeds the compiled SPA, so those third-party licences travel with the artifacts: THIRD-PARTY-NOTICES.md (generated by cargo make notices, kept current by the Licenses workflow). No dependency is under the GPL, the AGPL, or an LGPL-only licence; a handful are under the file-scoped MPL-2.0, which the notices file names explicitly.