1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
mod audit;
mod cli_config;
mod cmd;
mod http_client;
#[cfg(test)]
mod test_http;
mod updater;
use anyhow::Result;
use clap::{Parser, Subcommand};
use tracing::debug;
const DEFAULT_NATS: &str = "nats://127.0.0.1:4222";
const DEFAULT_BACKEND: &str = "http://127.0.0.1:8080";
#[derive(Parser, Debug)]
#[command(
name = "kanade",
about = "Admin CLI for the kanade endpoint management system",
version
)]
struct Cli {
/// NATS broker URL (not the backend).
///
/// Used by the broker subcommands: `run`, `kill`, `agent`,
/// `group` (except `group def`, which is HTTP — see --backend-url),
/// `meta`, `script`, `app`, `jetstream` (except `jetstream status`,
/// which is HTTP). Its credential is NOT a flag — the CLI reads
/// `HKLM\SOFTWARE\kanade\cli\NatsToken` (Windows; no installer
/// writes this today — a manual reg add), then
/// `HKLM\SOFTWARE\kanade\agent\NatsToken`, then
/// `$KANADE_NATS_TOKEN`, and connects unauthenticated if it finds
/// none of them.
#[arg(long, global = true, default_value = DEFAULT_NATS, env = "KANADE_NATS_URL")]
server: String,
/// Backend HTTP base URL.
///
/// Used by the HTTP subcommands: `job`, `schedule`, `exec`, `view`,
/// `query`, `freeze`, `account`, `group def`, `config`, `ping`, `revoke`,
/// `unrevoke`, `jetstream status`. They authenticate
/// WITH `$KANADE_AUTH_TOKEN`, a JWT — a different credential from
/// the broker token above, which is the usual source of confusion
/// when one set of subcommands works and the other does not.
/// `kanade login` also talks to the backend, but PRODUCES that JWT
/// rather than requiring it.
#[arg(long, global = true, default_value = DEFAULT_BACKEND, env = "KANADE_BACKEND_URL")]
backend_url: String,
#[command(subcommand)]
command: SubCmd,
}
#[derive(Subcommand, Debug)]
enum SubCmd {
/// Run a script on a target PC directly via NATS and wait for the result.
Run(cmd::run::RunArgs),
/// Ask the target PC's agent for a fresh heartbeat (via the backend API).
Ping(cmd::ping::PingArgs),
/// Manage JetStream streams + KV buckets (`status` goes through the
/// backend API; setup / delete / reset are NATS-direct).
Jetstream(cmd::jetstream::JetstreamArgs),
/// Mark a command id as REVOKED so agents skip it (spec §2.6 Layer 2).
/// Goes through the backend API (needs KANADE_AUTH_TOKEN), not NATS.
Revoke(cmd::revoke::RevokeArgs),
/// Re-mark a previously revoked command id as ACTIVE.
Unrevoke(cmd::revoke::UnrevokeArgs),
/// Publish kill.{exec_id} so agents running the exec terminate (spec §2.6 Layer 3).
Kill(cmd::kill::KillArgs),
/// Fire a registered job (`kanade job create` it first) at its declared targets.
Exec(cmd::exec::ExecArgs),
/// CRUD the job catalog (jobs KV). Schedules reference jobs by id.
Job(cmd::job::JobArgs),
/// CRUD cron schedules (spec §2.5.3).
Schedule(cmd::schedule::ScheduleArgs),
/// CRUD Analytics views (#743): declarative cross-cutting dashboards.
View(cmd::view::ViewArgs),
/// Fleet-wide change-freeze: stop all schedule fires (#418 Phase 5).
Freeze(cmd::freeze::FreezeArgs),
/// Manage agent releases (publish a new binary, query the target version).
Agent(cmd::agent::AgentArgs),
/// CRUD the generic app-package Object Store (`OBJECT_APP_PACKAGES`, #207).
/// Goes through the backend API (needs KANADE_AUTH_TOKEN), not NATS.
App(cmd::app::AppArgs),
/// CRUD the manifest-script Object Store (`OBJECT_SCRIPTS`, #211).
/// Bodies referenced by `execute.script_object` (#213 / #214). Goes
/// through the backend API (needs KANADE_AUTH_TOKEN), not NATS.
Script(cmd::script::ScriptArgs),
/// Manage the layered agent config (global / per-group / per-pc). Goes
/// through the backend API (needs KANADE_AUTH_TOKEN), not NATS.
Config(cmd::config::ConfigArgs),
/// Break-glass command-signing key (#1165). The backend's own key is minted
/// on the backend host (`kanade-backend command-key-generate`), never here.
CommandKey(cmd::command_key::CommandKeyArgs),
/// Manage groups: list fleet-wide, add/remove PC memberships,
/// list PCs in a given group. Goes through the backend API (needs
/// KANADE_AUTH_TOKEN), not NATS.
Group(cmd::group::GroupArgs),
/// Manage per-PC operator metadata (free-form key/value attributes on
/// the agent_meta KV bucket). NATS-direct.
Meta(cmd::meta::MetaArgs),
/// Log in with username/password; prints a JWT for KANADE_AUTH_TOKEN.
Login(cmd::login::LoginArgs),
/// Admin-only RBAC account management (create / role / disable / …).
Account(cmd::account::AccountArgs),
/// Run an ad-hoc read-only SQL query against the projector DB
/// (admin-only, SELECT/WITH only). Prints a table or `--json`.
Query(cmd::query::QueryArgs),
/// Update the kanade CLI itself from GitHub Releases (kaishin).
/// Background behaviour on ordinary runs is configured in the
/// per-user config (`[update] mode = off|notify|install`, default
/// notify); `KANADE_NO_AUTOUPDATE` disables it entirely.
SelfUpdate(cmd::self_update::SelfUpdateArgs),
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,kanade=debug".into()),
)
.init();
let cli = Cli::parse();
let Cli {
server,
backend_url,
command,
} = cli;
// Background update check (notify by default; see cli_config).
// Skipped for `self-update` itself — it IS the update path.
let update_handle = updater::maybe_spawn(matches!(command, SubCmd::SelfUpdate(_)));
let result = dispatch(server, backend_url, command).await;
updater::finalize(update_handle).await;
result
}
async fn dispatch(server: String, backend_url: String, command: SubCmd) -> Result<()> {
// HTTP-only subcommands (no NATS connect required).
if let SubCmd::Exec(args) = command {
return cmd::exec::execute(&backend_url, args).await;
} else if let SubCmd::Group(args) = command {
return cmd::group::execute(&backend_url, args).await;
} else if let SubCmd::Job(args) = command {
return cmd::job::execute(&backend_url, args).await;
} else if let SubCmd::Schedule(args) = command {
return cmd::schedule::execute(&backend_url, args).await;
} else if let SubCmd::View(args) = command {
return cmd::view::execute(&backend_url, args).await;
} else if let SubCmd::App(args) = command {
return cmd::app::execute(&backend_url, args).await;
} else if let SubCmd::Script(args) = command {
return cmd::script::execute(&backend_url, args).await;
} else if let SubCmd::Freeze(args) = command {
return cmd::freeze::execute(&backend_url, args).await;
} else if let SubCmd::Login(args) = command {
return cmd::login::execute(&backend_url, args).await;
} else if let SubCmd::Account(args) = command {
return cmd::account::execute(&backend_url, args).await;
} else if let SubCmd::Config(args) = command {
return cmd::config::execute(&backend_url, args).await;
} else if let SubCmd::Query(args) = command {
return cmd::query::execute(&backend_url, args).await;
} else if let SubCmd::Ping(args) = command {
return cmd::ping::execute(&backend_url, args).await;
} else if let SubCmd::Revoke(args) = command {
return cmd::revoke::revoke(&backend_url, args).await;
} else if let SubCmd::Unrevoke(args) = command {
return cmd::revoke::unrevoke(&backend_url, args).await;
} else if let SubCmd::Jetstream(cmd::jetstream::JetstreamArgs {
sub: cmd::jetstream::JetstreamSub::Status,
}) = command
{
return cmd::jetstream::status(&backend_url).await;
} else if let SubCmd::SelfUpdate(args) = command {
return cmd::self_update::execute(args).await;
} else if let SubCmd::CommandKey(args) = command {
// #1165: needs neither NATS nor the backend, and that is deliberate
// rather than incidental. This mints the credential for recovering from
// a dead backend; requiring a broker to produce it would put the
// recovery tool behind the thing it recovers from.
return cmd::command_key::execute(args);
}
// The remaining subcommands need NATS. The role decides which
// credential the helper looks for (#1155):
// `HKLM\SOFTWARE\kanade\cli\NatsToken` when provisioned, otherwise the
// fleet-wide token every role shared before roles existed, otherwise
// $KANADE_NATS_TOKEN — which is the branch an operator shell normally
// takes, since the CLI does not run as LocalSystem.
let client =
kanade_shared::nats_client::connect(kanade_shared::nats_client::NatsRole::Cli, &server)
.await?;
debug!("connected to NATS");
match command {
SubCmd::Run(args) => cmd::run::execute(client, args).await,
SubCmd::Jetstream(args) => cmd::jetstream::execute(client, args).await,
SubCmd::Kill(args) => cmd::kill::execute(client, args).await,
SubCmd::Agent(args) => cmd::agent::execute(client, args).await,
SubCmd::Meta(args) => cmd::meta::execute(client, args).await,
SubCmd::Exec(_)
| SubCmd::Job(_)
| SubCmd::Schedule(_)
| SubCmd::View(_)
| SubCmd::Group(_)
| SubCmd::App(_)
| SubCmd::Script(_)
| SubCmd::Config(_)
| SubCmd::Freeze(_)
| SubCmd::Ping(_)
| SubCmd::Revoke(_)
| SubCmd::Unrevoke(_)
| SubCmd::Login(_)
| SubCmd::Account(_)
| SubCmd::Query(_)
| SubCmd::SelfUpdate(_)
| SubCmd::CommandKey(_) => {
unreachable!("handled above")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_http::{fake_backend, seen};
/// `app` / `script` must be served without ever touching NATS: with a
/// broker address nothing listens on, a NATS-first dispatch would fail
/// to connect before sending any HTTP request.
#[tokio::test]
async fn app_and_script_dispatch_without_connecting_to_nats() {
let dead_nats = "nats://127.0.0.1:1".to_string();
let (base, log) = fake_backend(vec![(200, "[]"), (200, "[]")]).await;
let app = SubCmd::App(cmd::app::AppArgs {
sub: cmd::app::AppSub::List,
});
dispatch(dead_nats.clone(), base.clone(), app)
.await
.unwrap();
let script = SubCmd::Script(cmd::script::ScriptArgs {
sub: cmd::script::ScriptSub::List,
});
dispatch(dead_nats, base, script).await.unwrap();
let got = seen(&log);
assert_eq!(got[0].target, "/api/app-packages");
assert_eq!(got[1].target, "/api/script-objects");
}
}