admin cmds
This commit is contained in:
@@ -85,6 +85,26 @@ You can scaffold this file with:
|
|||||||
wakey-control-plane init-config
|
wakey-control-plane init-config
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Inspect current persisted state:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane state-stats
|
||||||
|
```
|
||||||
|
|
||||||
|
List/revoke enroll tokens from CLI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane list-enroll-tokens --include-expired
|
||||||
|
wakey-control-plane revoke-enroll-token --token enr-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Machine-readable output is available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane list-enroll-tokens --include-expired --json
|
||||||
|
wakey-control-plane state-stats --json
|
||||||
|
```
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -106,9 +126,11 @@ If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local
|
|||||||
structured logs are emitted.
|
structured logs are emitted.
|
||||||
|
|
||||||
State is persisted in an embedded `sled` database (default
|
State is persisted in an embedded `sled` database (default
|
||||||
`/var/lib/wakey-control-plane/state.db`) rather than single-file JSON snapshots.
|
`/var/lib/wakey-control-plane/state.db`).
|
||||||
Relative paths in config are resolved under `data_dir`.
|
Relative paths in config are resolved under `data_dir`.
|
||||||
|
|
||||||
|
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
|
||||||
|
|
||||||
### Quick start
|
### Quick start
|
||||||
|
|
||||||
Control-plane:
|
Control-plane:
|
||||||
@@ -155,6 +177,12 @@ During daemon control/state operations:
|
|||||||
- control-plane: `wrote control-plane pid file`, `saved control-plane store`, `reloaded control-plane store from disk`
|
- control-plane: `wrote control-plane pid file`, `saved control-plane store`, `reloaded control-plane store from disk`
|
||||||
- agent: `wrote wakey-agent pid file`, `sending wakey-agent reload signal`
|
- agent: `wrote wakey-agent pid file`, `sending wakey-agent reload signal`
|
||||||
|
|
||||||
|
Control-plane admin API includes token management endpoints:
|
||||||
|
|
||||||
|
- `POST /api/v1/control/enroll-token?ttl_seconds=<n>`
|
||||||
|
- `GET /api/v1/control/enroll-tokens?include_expired=true|false`
|
||||||
|
- `DELETE /api/v1/control/enroll-tokens/{token}`
|
||||||
|
|
||||||
If commands still appear silent, verify both processes are running with `-v`
|
If commands still appear silent, verify both processes are running with `-v`
|
||||||
and that `RUST_LOG` is not overriding to a stricter level.
|
and that `RUST_LOG` is not overriding to a stricter level.
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,24 @@ pub struct IssueEnrollTokenQuery {
|
|||||||
pub ttl_seconds: Option<u64>,
|
pub ttl_seconds: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListEnrollTokenQuery {
|
||||||
|
pub include_expired: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct EnrollTokenStatus {
|
||||||
|
pub enroll_token: String,
|
||||||
|
pub expires_at_unix: u64,
|
||||||
|
pub expired: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RevokeEnrollTokenResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AgentStatus {
|
pub struct AgentStatus {
|
||||||
pub agent_id: String,
|
pub agent_id: String,
|
||||||
@@ -135,6 +153,54 @@ pub async fn list_agents(
|
|||||||
Ok((StatusCode::OK, Json(agents)))
|
Ok((StatusCode::OK, Json(agents)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_enroll_tokens(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ListEnrollTokenQuery>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let include_expired = query.include_expired.unwrap_or(false);
|
||||||
|
match state.store.list_enroll_tokens(include_expired).await {
|
||||||
|
Ok(tokens) => {
|
||||||
|
let body = tokens
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| EnrollTokenStatus {
|
||||||
|
enroll_token: t.enroll_token,
|
||||||
|
expires_at_unix: t.expires_at_unix,
|
||||||
|
expired: t.expired,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Ok((StatusCode::OK, Json(body)))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to list enroll tokens");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"list_enroll_tokens_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_enroll_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(token): AxumPath<String>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
match state.store.revoke_enroll_token(&token).await {
|
||||||
|
Ok(revoked) => Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(RevokeEnrollTokenResponse { token, revoked }),
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to revoke enroll token");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"revoke_enroll_token_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run_command(
|
pub async fn run_command(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(agent_id): AxumPath<String>,
|
AxumPath(agent_id): AxumPath<String>,
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ pub enum Command {
|
|||||||
InitConfig(InitConfigArgs),
|
InitConfig(InitConfigArgs),
|
||||||
/// Create a new enroll token for provisioning a router.
|
/// Create a new enroll token for provisioning a router.
|
||||||
IssueEnrollToken(IssueEnrollTokenArgs),
|
IssueEnrollToken(IssueEnrollTokenArgs),
|
||||||
|
/// List current enroll tokens and their expiration status.
|
||||||
|
ListEnrollTokens(ListEnrollTokensArgs),
|
||||||
|
/// Revoke a specific enroll token.
|
||||||
|
RevokeEnrollToken(RevokeEnrollTokenArgs),
|
||||||
|
/// Print state backend stats.
|
||||||
|
StateStats(StateStatsArgs),
|
||||||
/// Send SIGHUP to an already-running daemon.
|
/// Send SIGHUP to an already-running daemon.
|
||||||
Reload(ReloadArgs),
|
Reload(ReloadArgs),
|
||||||
}
|
}
|
||||||
@@ -123,6 +129,54 @@ pub struct IssueEnrollTokenArgs {
|
|||||||
pub ttl_seconds: Option<u64>,
|
pub ttl_seconds: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct ListEnrollTokensArgs {
|
||||||
|
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
|
||||||
|
pub config_file: PathBuf,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub state_file: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub data_dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub include_expired: bool,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct RevokeEnrollTokenArgs {
|
||||||
|
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
|
||||||
|
pub config_file: PathBuf,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub state_file: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub data_dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct StateStatsArgs {
|
||||||
|
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
|
||||||
|
pub config_file: PathBuf,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub state_file: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub data_dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub json: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
pub struct ReloadArgs {
|
pub struct ReloadArgs {
|
||||||
#[arg(long, default_value = DEFAULT_PID_FILE)]
|
#[arg(long, default_value = DEFAULT_PID_FILE)]
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ use std::time::Duration;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::cli::{InitConfigArgs, IssueEnrollTokenArgs, ServeArgs};
|
use crate::cli::{
|
||||||
|
InitConfigArgs, IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeEnrollTokenArgs,
|
||||||
|
ServeArgs, StateStatsArgs,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DaemonConfig {
|
pub struct DaemonConfig {
|
||||||
@@ -279,22 +282,14 @@ pub struct IssueTokenSettings {
|
|||||||
pub ttl: Duration,
|
pub ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct StateAccessSettings {
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub state_file: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<IssueTokenSettings> {
|
pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<IssueTokenSettings> {
|
||||||
let file = load_file_config(&args.config_file)?;
|
let file = load_file_config(&args.config_file)?;
|
||||||
|
let state = resolve_state_access(&args.config_file, args.data_dir.clone(), args.state_file.clone())?;
|
||||||
let data_dir = args
|
|
||||||
.data_dir
|
|
||||||
.clone()
|
|
||||||
.or(file.data_dir)
|
|
||||||
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR));
|
|
||||||
|
|
||||||
let state_file = resolve_path(
|
|
||||||
&data_dir,
|
|
||||||
args.state_file
|
|
||||||
.clone()
|
|
||||||
.or(file.state_file)
|
|
||||||
.unwrap_or_else(|| PathBuf::from("state.db")),
|
|
||||||
);
|
|
||||||
|
|
||||||
let ttl = Duration::from_secs(
|
let ttl = Duration::from_secs(
|
||||||
args.ttl_seconds
|
args.ttl_seconds
|
||||||
@@ -304,8 +299,44 @@ pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<Issue
|
|||||||
);
|
);
|
||||||
|
|
||||||
Ok(IssueTokenSettings {
|
Ok(IssueTokenSettings {
|
||||||
data_dir,
|
data_dir: state.data_dir,
|
||||||
state_file,
|
state_file: state.state_file,
|
||||||
ttl,
|
ttl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_list_enroll_token_settings(args: &ListEnrollTokensArgs) -> Result<StateAccessSettings> {
|
||||||
|
resolve_state_access(&args.config_file, args.data_dir.clone(), args.state_file.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_revoke_enroll_token_settings(args: &RevokeEnrollTokenArgs) -> Result<StateAccessSettings> {
|
||||||
|
resolve_state_access(&args.config_file, args.data_dir.clone(), args.state_file.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_state_stats_settings(args: &StateStatsArgs) -> Result<StateAccessSettings> {
|
||||||
|
resolve_state_access(&args.config_file, args.data_dir.clone(), args.state_file.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_state_access(
|
||||||
|
config_file: &Path,
|
||||||
|
cli_data_dir: Option<PathBuf>,
|
||||||
|
cli_state_file: Option<PathBuf>,
|
||||||
|
) -> Result<StateAccessSettings> {
|
||||||
|
let file = load_file_config(config_file)?;
|
||||||
|
|
||||||
|
let data_dir = cli_data_dir
|
||||||
|
.or(file.data_dir)
|
||||||
|
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR));
|
||||||
|
|
||||||
|
let state_file = resolve_path(
|
||||||
|
&data_dir,
|
||||||
|
cli_state_file
|
||||||
|
.or(file.state_file)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("state.db")),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(StateAccessSettings {
|
||||||
|
data_dir,
|
||||||
|
state_file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ async fn main() -> Result<()> {
|
|||||||
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
||||||
runtime::issue_enroll_token(args).await
|
runtime::issue_enroll_token(args).await
|
||||||
}
|
}
|
||||||
|
Command::ListEnrollTokens(args) => {
|
||||||
|
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
||||||
|
runtime::list_enroll_tokens(args).await
|
||||||
|
}
|
||||||
|
Command::RevokeEnrollToken(args) => {
|
||||||
|
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
||||||
|
runtime::revoke_enroll_token(args).await
|
||||||
|
}
|
||||||
|
Command::StateStats(args) => {
|
||||||
|
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
||||||
|
runtime::state_stats(args).await
|
||||||
|
}
|
||||||
Command::Reload(args) => {
|
Command::Reload(args) => {
|
||||||
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
||||||
runtime::reload_daemon(&args.pid_file)
|
runtime::reload_daemon(&args.pid_file)
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ use axum::Router;
|
|||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::{Mutex, RwLock, mpsc, oneshot};
|
use tokio::sync::{Mutex, RwLock, mpsc, oneshot};
|
||||||
|
use tokio::time::MissedTickBehavior;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
use wakey_agent::protocol::{ErrorPayload, ServerMessage};
|
use wakey_agent::protocol::{ErrorPayload, ServerMessage};
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::cli::IssueEnrollTokenArgs;
|
use crate::cli::{IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeEnrollTokenArgs, StateStatsArgs};
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::state;
|
use crate::state;
|
||||||
use crate::ws;
|
use crate::ws;
|
||||||
@@ -57,6 +58,11 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
.route("/healthz", get(api::healthz))
|
.route("/healthz", get(api::healthz))
|
||||||
.route("/api/v1/agents/enroll", post(api::enroll))
|
.route("/api/v1/agents/enroll", post(api::enroll))
|
||||||
.route("/api/v1/control/enroll-token", post(api::issue_enroll_token))
|
.route("/api/v1/control/enroll-token", post(api::issue_enroll_token))
|
||||||
|
.route("/api/v1/control/enroll-tokens", get(api::list_enroll_tokens))
|
||||||
|
.route(
|
||||||
|
"/api/v1/control/enroll-tokens/{token}",
|
||||||
|
axum::routing::delete(api::revoke_enroll_token),
|
||||||
|
)
|
||||||
.route("/api/v1/agent/ws", get(ws::agent_ws))
|
.route("/api/v1/agent/ws", get(ws::agent_ws))
|
||||||
.route("/api/v1/control/agents", get(api::list_agents))
|
.route("/api/v1/control/agents", get(api::list_agents))
|
||||||
.route(
|
.route(
|
||||||
@@ -77,6 +83,8 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
{
|
{
|
||||||
use tokio::signal::unix::{SignalKind, signal};
|
use tokio::signal::unix::{SignalKind, signal};
|
||||||
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
|
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
|
||||||
|
let mut gc_tick = tokio::time::interval(Duration::from_secs(300));
|
||||||
|
gc_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -91,6 +99,16 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
Err(err) => warn!(error = %err, "failed to reload state from disk"),
|
Err(err) => warn!(error = %err, "failed to reload state from disk"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ = gc_tick.tick() => {
|
||||||
|
match app_state.store.gc_expired_enroll_tokens().await {
|
||||||
|
Ok(removed) => {
|
||||||
|
if removed > 0 {
|
||||||
|
info!(removed, "periodic gc removed expired enroll tokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => warn!(error = %err, "periodic gc failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
join = &mut server => {
|
join = &mut server => {
|
||||||
let _ = remove_pid_file(&daemon.pid_file);
|
let _ = remove_pid_file(&daemon.pid_file);
|
||||||
return join.context("control-plane join failed")?;
|
return join.context("control-plane join failed")?;
|
||||||
@@ -166,6 +184,59 @@ pub async fn issue_enroll_token(args: IssueEnrollTokenArgs) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_enroll_tokens(args: ListEnrollTokensArgs) -> Result<()> {
|
||||||
|
let settings = config::resolve_list_enroll_token_settings(&args)?;
|
||||||
|
let store = state::Store::load_or_init(&settings.state_file, Vec::new(), Duration::from_secs(1))
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to initialize store {}", settings.state_file.display()))?;
|
||||||
|
let tokens = store.list_enroll_tokens(args.include_expired).await?;
|
||||||
|
if args.json {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&tokens).context("failed to render json")?
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for token in tokens {
|
||||||
|
println!(
|
||||||
|
"token={} expires_at_unix={} expired={}",
|
||||||
|
token.enroll_token, token.expires_at_unix, token.expired
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_enroll_token(args: RevokeEnrollTokenArgs) -> Result<()> {
|
||||||
|
let settings = config::resolve_revoke_enroll_token_settings(&args)?;
|
||||||
|
let store = state::Store::load_or_init(&settings.state_file, Vec::new(), Duration::from_secs(1))
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to initialize store {}", settings.state_file.display()))?;
|
||||||
|
let removed = store.revoke_enroll_token(&args.token).await?;
|
||||||
|
println!("token={} revoked={}", args.token, removed);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn state_stats(args: StateStatsArgs) -> Result<()> {
|
||||||
|
let settings = config::resolve_state_stats_settings(&args)?;
|
||||||
|
let store = state::Store::load_or_init(&settings.state_file, Vec::new(), Duration::from_secs(1))
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to initialize store {}", settings.state_file.display()))?;
|
||||||
|
let stats = store.stats().await?;
|
||||||
|
if args.json {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&stats).context("failed to render json")?
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
println!("db_path={}", stats.db_path.display());
|
||||||
|
println!("schema_version={}", stats.schema_version);
|
||||||
|
println!("agent_count={}", stats.agent_count);
|
||||||
|
println!("enroll_token_count={}", stats.enroll_token_count);
|
||||||
|
println!("expired_enroll_token_count={}", stats.expired_enroll_token_count);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn reload_daemon(pid_file: &Path) -> Result<()> {
|
pub fn reload_daemon(pid_file: &Path) -> Result<()> {
|
||||||
let pid = read_pid(pid_file)?;
|
let pid = read_pid(pid_file)?;
|
||||||
info!(pid, pid_file = %pid_file.display(), "sending control-plane reload signal");
|
info!(pid, pid_file = %pid_file.display(), "sending control-plane reload signal");
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -20,20 +19,34 @@ pub struct IssuedEnrollToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct LegacyPersistedState {
|
pub struct EnrollTokenInfo {
|
||||||
enroll_tokens: std::collections::HashSet<String>,
|
pub enroll_token: String,
|
||||||
agents: HashMap<String, String>,
|
pub expires_at_unix: u64,
|
||||||
|
pub expired: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StateStats {
|
||||||
|
pub db_path: PathBuf,
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub agent_count: usize,
|
||||||
|
pub enroll_token_count: usize,
|
||||||
|
pub expired_enroll_token_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Store {
|
pub struct Store {
|
||||||
db_path: PathBuf,
|
db_path: PathBuf,
|
||||||
|
meta: sled::Tree,
|
||||||
enroll_tokens: sled::Tree,
|
enroll_tokens: sled::Tree,
|
||||||
agents: sled::Tree,
|
agents: sled::Tree,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCHEMA_VERSION_KEY: &[u8] = b"schema_version";
|
||||||
|
const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
pub async fn load_or_init(path: &Path, enroll_tokens: Vec<String>, seed_ttl: Duration) -> Result<Self> {
|
pub async fn load_or_init(path: &Path, enroll_tokens: Vec<String>, seed_ttl: Duration) -> Result<Self> {
|
||||||
let db_path = canonical_db_path(path);
|
let db_path = path.to_path_buf();
|
||||||
if let Some(parent) = db_path.parent() {
|
if let Some(parent) = db_path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.with_context(|| format!("failed to create state dir {}", parent.display()))?;
|
.with_context(|| format!("failed to create state dir {}", parent.display()))?;
|
||||||
@@ -41,6 +54,7 @@ impl Store {
|
|||||||
|
|
||||||
let db = sled::open(&db_path)
|
let db = sled::open(&db_path)
|
||||||
.with_context(|| format!("failed to open state db {}", db_path.display()))?;
|
.with_context(|| format!("failed to open state db {}", db_path.display()))?;
|
||||||
|
let meta_tree = db.open_tree("meta").context("failed to open meta tree")?;
|
||||||
let enroll_tree = db
|
let enroll_tree = db
|
||||||
.open_tree("enroll_tokens")
|
.open_tree("enroll_tokens")
|
||||||
.context("failed to open enroll_tokens tree")?;
|
.context("failed to open enroll_tokens tree")?;
|
||||||
@@ -48,11 +62,12 @@ impl Store {
|
|||||||
|
|
||||||
let store = Self {
|
let store = Self {
|
||||||
db_path,
|
db_path,
|
||||||
|
meta: meta_tree,
|
||||||
enroll_tokens: enroll_tree,
|
enroll_tokens: enroll_tree,
|
||||||
agents: agents_tree,
|
agents: agents_tree,
|
||||||
};
|
};
|
||||||
|
|
||||||
store.maybe_migrate_legacy_json(path)?;
|
store.ensure_schema_version()?;
|
||||||
|
|
||||||
for token in enroll_tokens {
|
for token in enroll_tokens {
|
||||||
let token = token.trim();
|
let token = token.trim();
|
||||||
@@ -66,7 +81,7 @@ impl Store {
|
|||||||
.with_context(|| format!("failed to seed enroll token into {}", store.db_path.display()))?;
|
.with_context(|| format!("failed to seed enroll token into {}", store.db_path.display()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
store.gc_expired_enroll_tokens()?;
|
store.gc_expired_enroll_tokens_inner()?;
|
||||||
|
|
||||||
store
|
store
|
||||||
.flush()
|
.flush()
|
||||||
@@ -137,6 +152,65 @@ impl Store {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_enroll_tokens(&self, include_expired: bool) -> Result<Vec<EnrollTokenInfo>> {
|
||||||
|
let now = now_unix();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for item in self.enroll_tokens.iter() {
|
||||||
|
let (token, value) = item.context("failed iterating enroll token tree")?;
|
||||||
|
let expires_at_unix = decode_expiry(value.as_ref()).context("failed decoding token expiry")?;
|
||||||
|
let expired = expires_at_unix <= now;
|
||||||
|
if !include_expired && expired {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let enroll_token = String::from_utf8(token.to_vec()).context("invalid utf-8 enroll token in db")?;
|
||||||
|
out.push(EnrollTokenInfo {
|
||||||
|
enroll_token,
|
||||||
|
expires_at_unix,
|
||||||
|
expired,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.expires_at_unix.cmp(&b.expires_at_unix).then(a.enroll_token.cmp(&b.enroll_token)));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_enroll_token(&self, token: &str) -> Result<bool> {
|
||||||
|
let removed = self
|
||||||
|
.enroll_tokens
|
||||||
|
.remove(token.as_bytes())
|
||||||
|
.context("failed removing enroll token")?
|
||||||
|
.is_some();
|
||||||
|
if removed {
|
||||||
|
self.flush().context("failed flushing db after enroll token revoke")?;
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stats(&self) -> Result<StateStats> {
|
||||||
|
let now = now_unix();
|
||||||
|
let mut enroll_token_count = 0usize;
|
||||||
|
let mut expired_enroll_token_count = 0usize;
|
||||||
|
for item in self.enroll_tokens.iter() {
|
||||||
|
let (_, value) = item.context("failed iterating enroll token tree")?;
|
||||||
|
let expires_at = decode_expiry(value.as_ref()).context("failed decoding token expiry during stats")?;
|
||||||
|
enroll_token_count = enroll_token_count.saturating_add(1);
|
||||||
|
if expires_at <= now {
|
||||||
|
expired_enroll_token_count = expired_enroll_token_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StateStats {
|
||||||
|
db_path: self.db_path.clone(),
|
||||||
|
schema_version: self.schema_version()?,
|
||||||
|
agent_count: self.agents.iter().count(),
|
||||||
|
enroll_token_count,
|
||||||
|
expired_enroll_token_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn gc_expired_enroll_tokens(&self) -> Result<u64> {
|
||||||
|
self.gc_expired_enroll_tokens_inner()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn reload_from_disk(&self) -> Result<()> {
|
pub async fn reload_from_disk(&self) -> Result<()> {
|
||||||
// sled is durable and read-through; explicit reload is a no-op.
|
// sled is durable and read-through; explicit reload is a no-op.
|
||||||
info!(path = %self.db_path.display(), "reload requested; sled backend does not require in-memory reload");
|
info!(path = %self.db_path.display(), "reload requested; sled backend does not require in-memory reload");
|
||||||
@@ -176,43 +250,7 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maybe_migrate_legacy_json(&self, configured_path: &Path) -> Result<()> {
|
fn gc_expired_enroll_tokens_inner(&self) -> Result<u64> {
|
||||||
let legacy_path = match configured_path.extension().and_then(|x| x.to_str()) {
|
|
||||||
Some("json") => configured_path.to_path_buf(),
|
|
||||||
_ => configured_path.with_extension("json"),
|
|
||||||
};
|
|
||||||
if self.enroll_tokens.iter().next().is_some() || self.agents.iter().next().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if !legacy_path.exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw = std::fs::read_to_string(&legacy_path)
|
|
||||||
.with_context(|| format!("failed to read legacy state {}", legacy_path.display()))?;
|
|
||||||
let legacy: LegacyPersistedState = serde_json::from_str(&raw)
|
|
||||||
.with_context(|| format!("failed to parse legacy state {}", legacy_path.display()))?;
|
|
||||||
|
|
||||||
let expires_at_unix = now_unix().saturating_add(86_400);
|
|
||||||
for token in legacy.enroll_tokens {
|
|
||||||
self.enroll_tokens
|
|
||||||
.insert(token.as_bytes(), &expires_at_unix.to_le_bytes())
|
|
||||||
.context("failed to migrate enroll token")?;
|
|
||||||
}
|
|
||||||
for (agent_id, token) in legacy.agents {
|
|
||||||
self.agents
|
|
||||||
.insert(agent_id.as_bytes(), token.as_bytes())
|
|
||||||
.context("failed to migrate agent token")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.flush().context("failed flushing migrated legacy state")?;
|
|
||||||
std::fs::remove_file(&legacy_path)
|
|
||||||
.with_context(|| format!("failed to delete legacy state {}", legacy_path.display()))?;
|
|
||||||
info!(legacy = %legacy_path.display(), db = %self.db_path.display(), "migrated legacy json state into sled db and deleted legacy file");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn gc_expired_enroll_tokens(&self) -> Result<()> {
|
|
||||||
let now = now_unix();
|
let now = now_unix();
|
||||||
let mut removed = 0u64;
|
let mut removed = 0u64;
|
||||||
for item in self.enroll_tokens.iter() {
|
for item in self.enroll_tokens.iter() {
|
||||||
@@ -229,14 +267,39 @@ impl Store {
|
|||||||
self.flush().context("failed flushing db after gc")?;
|
self.flush().context("failed flushing db after gc")?;
|
||||||
info!(removed, "garbage-collected expired enroll tokens");
|
info!(removed, "garbage-collected expired enroll tokens");
|
||||||
}
|
}
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_schema_version(&self) -> Result<()> {
|
||||||
|
match self.meta.get(SCHEMA_VERSION_KEY).context("failed reading schema version")? {
|
||||||
|
Some(raw) => {
|
||||||
|
let schema = decode_schema(raw.as_ref()).context("failed decoding schema version")?;
|
||||||
|
if schema != SCHEMA_VERSION {
|
||||||
|
anyhow::bail!(
|
||||||
|
"unsupported db schema version {}; expected {}",
|
||||||
|
schema,
|
||||||
|
SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.meta
|
||||||
|
.insert(SCHEMA_VERSION_KEY, &SCHEMA_VERSION.to_le_bytes())
|
||||||
|
.context("failed writing schema version")?;
|
||||||
|
self.flush().context("failed flushing db after schema init")?;
|
||||||
|
info!(schema_version = SCHEMA_VERSION, "initialized state schema version");
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn canonical_db_path(configured: &Path) -> PathBuf {
|
fn schema_version(&self) -> Result<u32> {
|
||||||
match configured.extension().and_then(|x| x.to_str()) {
|
let raw = self
|
||||||
Some("json") => configured.with_extension("db"),
|
.meta
|
||||||
_ => configured.to_path_buf(),
|
.get(SCHEMA_VERSION_KEY)
|
||||||
|
.context("failed reading schema version")?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing schema version in state db"))?;
|
||||||
|
decode_schema(raw.as_ref()).context("failed decoding schema version")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,3 +318,12 @@ fn decode_expiry(raw: &[u8]) -> Result<u64> {
|
|||||||
arr.copy_from_slice(raw);
|
arr.copy_from_slice(raw);
|
||||||
Ok(u64::from_le_bytes(arr))
|
Ok(u64::from_le_bytes(arr))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_schema(raw: &[u8]) -> Result<u32> {
|
||||||
|
if raw.len() != 4 {
|
||||||
|
anyhow::bail!("invalid schema version length {}", raw.len());
|
||||||
|
}
|
||||||
|
let mut arr = [0u8; 4];
|
||||||
|
arr.copy_from_slice(raw);
|
||||||
|
Ok(u32::from_le_bytes(arr))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user