trust level 100 w ts one
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
# Wakey Checkpoint (2026-04-11)
|
||||||
|
|
||||||
|
## Snapshot
|
||||||
|
|
||||||
|
This checkpoint captures the current state after control-plane migration, logging hardening, config ergonomics, and state storage upgrades.
|
||||||
|
|
||||||
|
## What Is Done
|
||||||
|
|
||||||
|
- Legacy router-hosted HTTP/static layer removed from `wakey` crate.
|
||||||
|
- New `wakey-control-plane` crate is active for:
|
||||||
|
- enroll-token issuance
|
||||||
|
- agent enrollment
|
||||||
|
- connected-agent websocket registry
|
||||||
|
- command relay to agent
|
||||||
|
- `wakey-agent` is active for outbound websocket execution and local command dispatch.
|
||||||
|
|
||||||
|
## Logging + Telemetry
|
||||||
|
|
||||||
|
- High-signal logs were added across:
|
||||||
|
- control-plane API relay path
|
||||||
|
- control-plane websocket lifecycle
|
||||||
|
- control-plane state lifecycle
|
||||||
|
- agent command lifecycle, session lifecycle, and enrollment
|
||||||
|
- Correlated relay spans include command context (`agent_id`, `request_id`, `command`).
|
||||||
|
- Control-plane telemetry is config-driven:
|
||||||
|
- optional OTLP endpoint
|
||||||
|
- optional JSON logs
|
||||||
|
- fallback local logs when OTLP endpoint is not set
|
||||||
|
|
||||||
|
## Config Ergonomics
|
||||||
|
|
||||||
|
### Control-plane
|
||||||
|
|
||||||
|
- Config file support is wired (`/etc/wakey-control-plane/config.toml` by default).
|
||||||
|
- `serve` can read defaults from config file and CLI can override.
|
||||||
|
- New `init-config` command scaffolds a control-plane config file.
|
||||||
|
|
||||||
|
### Agent
|
||||||
|
|
||||||
|
- Existing `init-config` command scaffolds agent config.
|
||||||
|
- Enrollment can optionally signal reload of running daemon.
|
||||||
|
|
||||||
|
## State Storage Upgrade
|
||||||
|
|
||||||
|
- Control-plane state backend moved from JSON snapshot to embedded `sled` DB.
|
||||||
|
- Default state path changed to `/var/lib/wakey-control-plane/state.db`.
|
||||||
|
- Legacy JSON migration support exists:
|
||||||
|
- if a `.json` path is configured and DB is empty, tokens/agents are migrated into DB.
|
||||||
|
|
||||||
|
## Operator Commands
|
||||||
|
|
||||||
|
### Control-plane bootstrap
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane init-config
|
||||||
|
wakey-control-plane serve --config-file /etc/wakey-control-plane/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue enroll token (live daemon path)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane issue-enroll-token --public-url https://cp.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent bootstrap
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-agent enroll --server-url https://cp.example.com --enroll-token <token>
|
||||||
|
wakey-agent serve --config /etc/wakey-agent/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Health
|
||||||
|
|
||||||
|
- Last verified passing:
|
||||||
|
- `cargo check --workspace`
|
||||||
|
- `cargo clippy --workspace`
|
||||||
|
|
||||||
|
## Known Tradeoffs / Follow-ups
|
||||||
|
|
||||||
|
- Reload semantics with `sled` are now mostly no-op for in-memory state (data is durable in DB).
|
||||||
|
- No dedicated state-inspection CLI command yet (suggestion: add `state-stats` command).
|
||||||
|
- OTLP configuration is currently control-plane focused; agent parity can be added if needed.
|
||||||
|
|
||||||
|
## Suggested Next Steps
|
||||||
|
|
||||||
|
1. Add a control-plane `state-stats` command to print DB path, agent count, token count.
|
||||||
|
2. Add symmetric telemetry config support in `wakey-agent` config file.
|
||||||
|
3. Add integration tests for:
|
||||||
|
- enroll + relay over websocket
|
||||||
|
- legacy JSON-to-sled migration path
|
||||||
|
- init-config command behavior and overrides
|
||||||
@@ -88,11 +88,13 @@ wakey-control-plane init-config
|
|||||||
Example:
|
Example:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
data_dir = "/var/lib/wakey-control-plane"
|
||||||
bind = "0.0.0.0:8080"
|
bind = "0.0.0.0:8080"
|
||||||
public_url = "https://cp.example.com"
|
public_url = "https://cp.example.com"
|
||||||
state_file = "/var/lib/wakey-control-plane/state.db"
|
state_file = "state.db"
|
||||||
pid_file = "/var/run/wakey-control-plane.pid"
|
pid_file = "wakey-control-plane.pid"
|
||||||
command_timeout_ms = 30000
|
command_timeout_ms = 30000
|
||||||
|
enroll_token_ttl_seconds = 86400
|
||||||
|
|
||||||
[telemetry]
|
[telemetry]
|
||||||
otlp_endpoint = "http://127.0.0.1:4317"
|
otlp_endpoint = "http://127.0.0.1:4317"
|
||||||
@@ -105,6 +107,7 @@ 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`) rather than single-file JSON snapshots.
|
||||||
|
Relative paths in config are resolved under `data_dir`.
|
||||||
|
|
||||||
### Quick start
|
### Quick start
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path as AxumPath, State};
|
use axum::extract::{Path as AxumPath, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -24,6 +24,12 @@ pub struct EnrollResponse {
|
|||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct IssueEnrollTokenResponse {
|
pub struct IssueEnrollTokenResponse {
|
||||||
pub enroll_token: String,
|
pub enroll_token: String,
|
||||||
|
pub expires_at_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct IssueEnrollTokenQuery {
|
||||||
|
pub ttl_seconds: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -81,14 +87,23 @@ pub async fn enroll(
|
|||||||
|
|
||||||
pub async fn issue_enroll_token(
|
pub async fn issue_enroll_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<IssueEnrollTokenQuery>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
match state.store.issue_enroll_token().await {
|
let ttl = std::time::Duration::from_secs(
|
||||||
Ok(token) => {
|
query
|
||||||
info!("issued enroll token");
|
.ttl_seconds
|
||||||
|
.unwrap_or(state.enroll_token_ttl.as_secs())
|
||||||
|
.max(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
match state.store.issue_enroll_token(ttl).await {
|
||||||
|
Ok(issued) => {
|
||||||
|
info!(expires_at_unix = issued.expires_at_unix, "issued enroll token");
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
Json(IssueEnrollTokenResponse {
|
Json(IssueEnrollTokenResponse {
|
||||||
enroll_token: token,
|
enroll_token: issued.enroll_token,
|
||||||
|
expires_at_unix: issued.expires_at_unix,
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use clap::{ArgAction, Args, Parser, Subcommand};
|
use clap::{ArgAction, Args, Parser, Subcommand};
|
||||||
|
|
||||||
pub const DEFAULT_STATE_FILE: &str = "/var/lib/wakey-control-plane/state.db";
|
pub const DEFAULT_PID_FILE: &str = "/var/lib/wakey-control-plane/wakey-control-plane.pid";
|
||||||
pub const DEFAULT_PID_FILE: &str = "/var/run/wakey-control-plane.pid";
|
pub const DEFAULT_DATA_DIR: &str = "/var/lib/wakey-control-plane";
|
||||||
pub const DEFAULT_CONFIG_FILE: &str = "/etc/wakey-control-plane/config.toml";
|
pub const DEFAULT_CONFIG_FILE: &str = "/etc/wakey-control-plane/config.toml";
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -32,6 +32,9 @@ pub enum Command {
|
|||||||
|
|
||||||
#[derive(Args, Clone)]
|
#[derive(Args, Clone)]
|
||||||
pub struct ServeArgs {
|
pub struct ServeArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
pub data_dir: Option<PathBuf>,
|
||||||
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub bind: Option<SocketAddr>,
|
pub bind: Option<SocketAddr>,
|
||||||
|
|
||||||
@@ -47,6 +50,9 @@ pub struct ServeArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub command_timeout_ms: Option<u64>,
|
pub command_timeout_ms: Option<u64>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub enroll_token_ttl_seconds: Option<u64>,
|
||||||
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub pid_file: Option<PathBuf>,
|
pub pid_file: Option<PathBuf>,
|
||||||
|
|
||||||
@@ -59,6 +65,9 @@ pub struct InitConfigArgs {
|
|||||||
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
|
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
|
||||||
pub config_file: PathBuf,
|
pub config_file: PathBuf,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub data_dir: Option<PathBuf>,
|
||||||
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub bind: Option<SocketAddr>,
|
pub bind: Option<SocketAddr>,
|
||||||
|
|
||||||
@@ -74,6 +83,9 @@ pub struct InitConfigArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub command_timeout_ms: Option<u64>,
|
pub command_timeout_ms: Option<u64>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub enroll_token_ttl_seconds: Option<u64>,
|
||||||
|
|
||||||
#[arg(long = "enroll-token")]
|
#[arg(long = "enroll-token")]
|
||||||
pub enroll_tokens: Vec<String>,
|
pub enroll_tokens: Vec<String>,
|
||||||
|
|
||||||
@@ -92,14 +104,23 @@ pub struct InitConfigArgs {
|
|||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
pub struct IssueEnrollTokenArgs {
|
pub struct IssueEnrollTokenArgs {
|
||||||
#[arg(long, default_value = DEFAULT_STATE_FILE)]
|
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
|
||||||
pub state_file: PathBuf,
|
pub config_file: PathBuf,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub state_file: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub data_dir: Option<PathBuf>,
|
||||||
|
|
||||||
#[arg(long = "enroll-token")]
|
#[arg(long = "enroll-token")]
|
||||||
pub enroll_tokens: Vec<String>,
|
pub enroll_tokens: Vec<String>,
|
||||||
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub public_url: Option<String>,
|
pub public_url: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub ttl_seconds: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ use std::time::Duration;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::cli::{InitConfigArgs, ServeArgs};
|
use crate::cli::{InitConfigArgs, IssueEnrollTokenArgs, ServeArgs};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DaemonConfig {
|
pub struct DaemonConfig {
|
||||||
|
pub data_dir: PathBuf,
|
||||||
pub bind: SocketAddr,
|
pub bind: SocketAddr,
|
||||||
pub public_url: String,
|
pub public_url: String,
|
||||||
pub state_file: PathBuf,
|
pub state_file: PathBuf,
|
||||||
pub command_timeout: Duration,
|
pub command_timeout: Duration,
|
||||||
|
pub enroll_token_ttl: Duration,
|
||||||
pub pid_file: PathBuf,
|
pub pid_file: PathBuf,
|
||||||
pub enroll_tokens: Vec<String>,
|
pub enroll_tokens: Vec<String>,
|
||||||
pub telemetry: TelemetryConfig,
|
pub telemetry: TelemetryConfig,
|
||||||
@@ -38,10 +40,12 @@ impl Default for TelemetryConfig {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
#[derive(Debug, Deserialize, Default)]
|
||||||
struct FileConfig {
|
struct FileConfig {
|
||||||
|
data_dir: Option<PathBuf>,
|
||||||
bind: Option<String>,
|
bind: Option<String>,
|
||||||
public_url: Option<String>,
|
public_url: Option<String>,
|
||||||
state_file: Option<PathBuf>,
|
state_file: Option<PathBuf>,
|
||||||
command_timeout_ms: Option<u64>,
|
command_timeout_ms: Option<u64>,
|
||||||
|
enroll_token_ttl_seconds: Option<u64>,
|
||||||
pid_file: Option<PathBuf>,
|
pid_file: Option<PathBuf>,
|
||||||
enroll_tokens: Option<Vec<String>>,
|
enroll_tokens: Option<Vec<String>>,
|
||||||
telemetry: Option<FileTelemetryConfig>,
|
telemetry: Option<FileTelemetryConfig>,
|
||||||
@@ -56,10 +60,12 @@ struct FileTelemetryConfig {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct WritableConfig {
|
struct WritableConfig {
|
||||||
|
data_dir: PathBuf,
|
||||||
bind: String,
|
bind: String,
|
||||||
public_url: String,
|
public_url: String,
|
||||||
state_file: PathBuf,
|
state_file: PathBuf,
|
||||||
command_timeout_ms: u64,
|
command_timeout_ms: u64,
|
||||||
|
enroll_token_ttl_seconds: u64,
|
||||||
pid_file: PathBuf,
|
pid_file: PathBuf,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
enroll_tokens: Vec<String>,
|
enroll_tokens: Vec<String>,
|
||||||
@@ -78,6 +84,12 @@ impl DaemonConfig {
|
|||||||
pub fn from_serve_args(args: &ServeArgs) -> Result<Self> {
|
pub fn from_serve_args(args: &ServeArgs) -> Result<Self> {
|
||||||
let file = load_file_config(&args.config_file)?;
|
let file = load_file_config(&args.config_file)?;
|
||||||
|
|
||||||
|
let data_dir = args
|
||||||
|
.data_dir
|
||||||
|
.clone()
|
||||||
|
.or(file.data_dir.clone())
|
||||||
|
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR));
|
||||||
|
|
||||||
let bind = match args.bind {
|
let bind = match args.bind {
|
||||||
Some(bind) => bind,
|
Some(bind) => bind,
|
||||||
None => match file.bind {
|
None => match file.bind {
|
||||||
@@ -95,11 +107,12 @@ impl DaemonConfig {
|
|||||||
.unwrap_or("http://127.0.0.1:8080"),
|
.unwrap_or("http://127.0.0.1:8080"),
|
||||||
);
|
);
|
||||||
|
|
||||||
let state_file = args
|
let state_file_raw = args
|
||||||
.state_file
|
.state_file
|
||||||
.clone()
|
.clone()
|
||||||
.or(file.state_file)
|
.or(file.state_file)
|
||||||
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_STATE_FILE));
|
.unwrap_or_else(|| PathBuf::from("state.db"));
|
||||||
|
let state_file = resolve_path(&data_dir, state_file_raw);
|
||||||
|
|
||||||
let command_timeout = Duration::from_millis(
|
let command_timeout = Duration::from_millis(
|
||||||
args.command_timeout_ms
|
args.command_timeout_ms
|
||||||
@@ -108,11 +121,19 @@ impl DaemonConfig {
|
|||||||
.max(1),
|
.max(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
let pid_file = args
|
let enroll_token_ttl = Duration::from_secs(
|
||||||
|
args.enroll_token_ttl_seconds
|
||||||
|
.or(file.enroll_token_ttl_seconds)
|
||||||
|
.unwrap_or(86_400)
|
||||||
|
.max(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
let pid_file_raw = args
|
||||||
.pid_file
|
.pid_file
|
||||||
.clone()
|
.clone()
|
||||||
.or(file.pid_file)
|
.or(file.pid_file)
|
||||||
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_PID_FILE));
|
.unwrap_or_else(|| PathBuf::from("wakey-control-plane.pid"));
|
||||||
|
let pid_file = resolve_path(&data_dir, pid_file_raw);
|
||||||
|
|
||||||
let enroll_tokens = if args.enroll_tokens.is_empty() {
|
let enroll_tokens = if args.enroll_tokens.is_empty() {
|
||||||
file.enroll_tokens.unwrap_or_default()
|
file.enroll_tokens.unwrap_or_default()
|
||||||
@@ -123,10 +144,12 @@ impl DaemonConfig {
|
|||||||
let telemetry = resolve_telemetry(file.telemetry);
|
let telemetry = resolve_telemetry(file.telemetry);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
data_dir,
|
||||||
bind,
|
bind,
|
||||||
public_url,
|
public_url,
|
||||||
state_file,
|
state_file,
|
||||||
command_timeout,
|
command_timeout,
|
||||||
|
enroll_token_ttl,
|
||||||
pid_file,
|
pid_file,
|
||||||
enroll_tokens,
|
enroll_tokens,
|
||||||
telemetry,
|
telemetry,
|
||||||
@@ -195,18 +218,31 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<()> {
|
|||||||
.unwrap_or("http://127.0.0.1:8080"),
|
.unwrap_or("http://127.0.0.1:8080"),
|
||||||
);
|
);
|
||||||
|
|
||||||
let body = WritableConfig {
|
let data_dir = args
|
||||||
bind: bind.to_string(),
|
.data_dir
|
||||||
public_url,
|
.clone()
|
||||||
state_file: args
|
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR));
|
||||||
|
|
||||||
|
let state_file_raw = args
|
||||||
.state_file
|
.state_file
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_STATE_FILE)),
|
.unwrap_or_else(|| PathBuf::from("state.db"));
|
||||||
command_timeout_ms: args.command_timeout_ms.unwrap_or(30_000).max(1),
|
let state_file = resolve_path(&data_dir, state_file_raw);
|
||||||
pid_file: args
|
|
||||||
|
let pid_file_raw = args
|
||||||
.pid_file
|
.pid_file
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_PID_FILE)),
|
.unwrap_or_else(|| PathBuf::from("wakey-control-plane.pid"));
|
||||||
|
let pid_file = resolve_path(&data_dir, pid_file_raw);
|
||||||
|
|
||||||
|
let body = WritableConfig {
|
||||||
|
data_dir,
|
||||||
|
bind: bind.to_string(),
|
||||||
|
public_url,
|
||||||
|
state_file,
|
||||||
|
command_timeout_ms: args.command_timeout_ms.unwrap_or(30_000).max(1),
|
||||||
|
enroll_token_ttl_seconds: args.enroll_token_ttl_seconds.unwrap_or(86_400).max(1),
|
||||||
|
pid_file,
|
||||||
enroll_tokens: args.enroll_tokens.clone(),
|
enroll_tokens: args.enroll_tokens.clone(),
|
||||||
telemetry: WritableTelemetry {
|
telemetry: WritableTelemetry {
|
||||||
otlp_endpoint: args.telemetry_otlp_endpoint.clone(),
|
otlp_endpoint: args.telemetry_otlp_endpoint.clone(),
|
||||||
@@ -228,3 +264,48 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<()> {
|
|||||||
.with_context(|| format!("failed to write config {}", args.config_file.display()))?;
|
.with_context(|| format!("failed to write config {}", args.config_file.display()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_path(data_dir: &Path, candidate: PathBuf) -> PathBuf {
|
||||||
|
if candidate.is_absolute() {
|
||||||
|
candidate
|
||||||
|
} else {
|
||||||
|
data_dir.join(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct IssueTokenSettings {
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub state_file: PathBuf,
|
||||||
|
pub ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<IssueTokenSettings> {
|
||||||
|
let file = load_file_config(&args.config_file)?;
|
||||||
|
|
||||||
|
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(
|
||||||
|
args.ttl_seconds
|
||||||
|
.or(file.enroll_token_ttl_seconds)
|
||||||
|
.unwrap_or(86_400)
|
||||||
|
.max(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(IssueTokenSettings {
|
||||||
|
data_dir,
|
||||||
|
state_file,
|
||||||
|
ttl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct AppState {
|
|||||||
pub pending: Arc<Mutex<HashMap<String, oneshot::Sender<AgentReply>>>>,
|
pub pending: Arc<Mutex<HashMap<String, oneshot::Sender<AgentReply>>>>,
|
||||||
pub public_url: String,
|
pub public_url: String,
|
||||||
pub command_timeout: Duration,
|
pub command_timeout: Duration,
|
||||||
|
pub enroll_token_ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum AgentReply {
|
pub enum AgentReply {
|
||||||
@@ -35,7 +36,11 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
write_pid_file(&daemon.pid_file)?;
|
write_pid_file(&daemon.pid_file)?;
|
||||||
info!(pid_file = %daemon.pid_file.display(), "wrote control-plane pid file");
|
info!(pid_file = %daemon.pid_file.display(), "wrote control-plane pid file");
|
||||||
|
|
||||||
let store = state::Store::load_or_init(&daemon.state_file, daemon.enroll_tokens.clone())
|
let store = state::Store::load_or_init(
|
||||||
|
&daemon.state_file,
|
||||||
|
daemon.enroll_tokens.clone(),
|
||||||
|
daemon.enroll_token_ttl,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("failed to initialize store {}", daemon.state_file.display()))?;
|
.with_context(|| format!("failed to initialize store {}", daemon.state_file.display()))?;
|
||||||
|
|
||||||
@@ -45,6 +50,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
public_url: daemon.public_url.clone(),
|
public_url: daemon.public_url.clone(),
|
||||||
command_timeout: daemon.command_timeout,
|
command_timeout: daemon.command_timeout,
|
||||||
|
enroll_token_ttl: daemon.enroll_token_ttl,
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -59,7 +65,7 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
)
|
)
|
||||||
.with_state(app_state.clone());
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
info!(bind = %daemon.bind, pid_file = %daemon.pid_file.display(), "starting control-plane server");
|
info!(bind = %daemon.bind, data_dir = %daemon.data_dir.display(), pid_file = %daemon.pid_file.display(), state_file = %daemon.state_file.display(), "starting control-plane server");
|
||||||
let listener = TcpListener::bind(daemon.bind).await?;
|
let listener = TcpListener::bind(daemon.bind).await?;
|
||||||
let mut server = tokio::spawn(async move {
|
let mut server = tokio::spawn(async move {
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
@@ -106,9 +112,12 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn issue_enroll_token(args: IssueEnrollTokenArgs) -> Result<()> {
|
pub async fn issue_enroll_token(args: IssueEnrollTokenArgs) -> Result<()> {
|
||||||
|
let settings = config::resolve_issue_token_settings(&args)?;
|
||||||
|
|
||||||
if let Some(url) = args.public_url {
|
if let Some(url) = args.public_url {
|
||||||
let base = config::normalize_public_url(&url);
|
let base = config::normalize_public_url(&url);
|
||||||
let endpoint = config::issue_token_endpoint(&base);
|
let ttl_seconds = settings.ttl.as_secs().max(1);
|
||||||
|
let endpoint = format!("{}?ttl_seconds={ttl_seconds}", config::issue_token_endpoint(&base));
|
||||||
info!(endpoint = %endpoint, "requesting live enroll token from running control-plane daemon");
|
info!(endpoint = %endpoint, "requesting live enroll token from running control-plane daemon");
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
@@ -134,6 +143,7 @@ pub async fn issue_enroll_token(args: IssueEnrollTokenArgs) -> Result<()> {
|
|||||||
info!("received live enroll token response");
|
info!("received live enroll token response");
|
||||||
|
|
||||||
println!("enroll_token={}", payload.enroll_token);
|
println!("enroll_token={}", payload.enroll_token);
|
||||||
|
println!("expires_at_unix={}", payload.expires_at_unix);
|
||||||
println!(
|
println!(
|
||||||
"agent_command=wakey-agent enroll --server-url {base} --enroll-token {}",
|
"agent_command=wakey-agent enroll --server-url {base} --enroll-token {}",
|
||||||
payload.enroll_token
|
payload.enroll_token
|
||||||
@@ -142,15 +152,16 @@ pub async fn issue_enroll_token(args: IssueEnrollTokenArgs) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for offline tooling: writes to state file, requires daemon reload to pick up.
|
// Fallback for offline tooling: writes to state file, requires daemon reload to pick up.
|
||||||
info!(state_file = %args.state_file.display(), "issuing enroll token via offline state file fallback");
|
info!(data_dir = %settings.data_dir.display(), state_file = %settings.state_file.display(), ttl_seconds = settings.ttl.as_secs(), "issuing enroll token via offline state file fallback");
|
||||||
let store = state::Store::load_or_init(&args.state_file, args.enroll_tokens)
|
let store = state::Store::load_or_init(&settings.state_file, args.enroll_tokens, settings.ttl)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("failed to initialize store {}", args.state_file.display()))?;
|
.with_context(|| format!("failed to initialize store {}", settings.state_file.display()))?;
|
||||||
let token = store.issue_enroll_token().await?;
|
let issued = store.issue_enroll_token(settings.ttl).await?;
|
||||||
println!("enroll_token={token}");
|
println!("enroll_token={}", issued.enroll_token);
|
||||||
|
println!("expires_at_unix={}", issued.expires_at_unix);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"note: token was written to {}. running daemon must reload state to see it",
|
"note: token was written to {}. running daemon must reload state to see it",
|
||||||
args.state_file.display()
|
settings.state_file.display()
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -12,6 +13,12 @@ pub struct IssuedAgent {
|
|||||||
pub agent_token: String,
|
pub agent_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IssuedEnrollToken {
|
||||||
|
pub enroll_token: String,
|
||||||
|
pub expires_at_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct LegacyPersistedState {
|
struct LegacyPersistedState {
|
||||||
enroll_tokens: std::collections::HashSet<String>,
|
enroll_tokens: std::collections::HashSet<String>,
|
||||||
@@ -25,7 +32,7 @@ pub struct Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
pub async fn load_or_init(path: &Path, enroll_tokens: Vec<String>) -> 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 = canonical_db_path(path);
|
||||||
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)
|
||||||
@@ -52,12 +59,15 @@ impl Store {
|
|||||||
if token.is_empty() {
|
if token.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let expires_at = now_unix().saturating_add(seed_ttl.as_secs().max(1));
|
||||||
store
|
store
|
||||||
.enroll_tokens
|
.enroll_tokens
|
||||||
.insert(token.as_bytes(), &[])
|
.insert(token.as_bytes(), &expires_at.to_le_bytes())
|
||||||
.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
|
store
|
||||||
.flush()
|
.flush()
|
||||||
.with_context(|| format!("failed to flush state db {}", store.db_path.display()))?;
|
.with_context(|| format!("failed to flush state db {}", store.db_path.display()))?;
|
||||||
@@ -74,16 +84,29 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn enroll(&self, enroll_token: &str) -> Result<IssuedAgent> {
|
pub async fn enroll(&self, enroll_token: &str) -> Result<IssuedAgent> {
|
||||||
if self
|
let Some(raw_expiry) = self
|
||||||
.enroll_tokens
|
.enroll_tokens
|
||||||
.remove(enroll_token.as_bytes())
|
.get(enroll_token.as_bytes())
|
||||||
.context("failed removing enroll token")?
|
.context("failed reading enroll token")?
|
||||||
.is_none()
|
else {
|
||||||
{
|
warn!("rejecting enroll attempt with invalid, expired, or consumed token");
|
||||||
warn!("rejecting enroll attempt with invalid or consumed token");
|
|
||||||
anyhow::bail!("invalid or already-used enroll token");
|
anyhow::bail!("invalid or already-used enroll token");
|
||||||
|
};
|
||||||
|
|
||||||
|
let expires_at_unix = decode_expiry(raw_expiry.as_ref())
|
||||||
|
.context("failed decoding enroll token expiry")?;
|
||||||
|
let now = now_unix();
|
||||||
|
if expires_at_unix <= now {
|
||||||
|
let _ = self.enroll_tokens.remove(enroll_token.as_bytes());
|
||||||
|
self.flush().ok();
|
||||||
|
warn!(expires_at_unix, now_unix = now, "rejecting expired enroll token");
|
||||||
|
anyhow::bail!("enroll token has expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.enroll_tokens
|
||||||
|
.remove(enroll_token.as_bytes())
|
||||||
|
.context("failed consuming enroll token")?;
|
||||||
|
|
||||||
let agent_id = format!("agent-{}", Uuid::new_v4());
|
let agent_id = format!("agent-{}", Uuid::new_v4());
|
||||||
let agent_token = format!("tok-{}", Uuid::new_v4());
|
let agent_token = format!("tok-{}", Uuid::new_v4());
|
||||||
|
|
||||||
@@ -99,15 +122,19 @@ impl Store {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn issue_enroll_token(&self) -> Result<String> {
|
pub async fn issue_enroll_token(&self, ttl: Duration) -> Result<IssuedEnrollToken> {
|
||||||
let token = format!("enr-{}", Uuid::new_v4());
|
let token = format!("enr-{}", Uuid::new_v4());
|
||||||
|
let expires_at_unix = now_unix().saturating_add(ttl.as_secs().max(1));
|
||||||
self.enroll_tokens
|
self.enroll_tokens
|
||||||
.insert(token.as_bytes(), &[])
|
.insert(token.as_bytes(), &expires_at_unix.to_le_bytes())
|
||||||
.context("failed persisting enroll token")?;
|
.context("failed persisting enroll token")?;
|
||||||
self.flush()
|
self.flush()
|
||||||
.context("failed flushing state db after token issuance")?;
|
.context("failed flushing state db after token issuance")?;
|
||||||
info!("persisted new enroll token");
|
info!(expires_at_unix, "persisted new enroll token");
|
||||||
Ok(token)
|
Ok(IssuedEnrollToken {
|
||||||
|
enroll_token: token,
|
||||||
|
expires_at_unix,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn reload_from_disk(&self) -> Result<()> {
|
pub async fn reload_from_disk(&self) -> Result<()> {
|
||||||
@@ -150,24 +177,26 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn maybe_migrate_legacy_json(&self, configured_path: &Path) -> Result<()> {
|
fn maybe_migrate_legacy_json(&self, configured_path: &Path) -> Result<()> {
|
||||||
if configured_path.extension().and_then(|x| x.to_str()) != Some("json") {
|
let legacy_path = match configured_path.extension().and_then(|x| x.to_str()) {
|
||||||
return Ok(());
|
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() {
|
if self.enroll_tokens.iter().next().is_some() || self.agents.iter().next().is_some() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if !configured_path.exists() {
|
if !legacy_path.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw = std::fs::read_to_string(configured_path)
|
let raw = std::fs::read_to_string(&legacy_path)
|
||||||
.with_context(|| format!("failed to read legacy state {}", configured_path.display()))?;
|
.with_context(|| format!("failed to read legacy state {}", legacy_path.display()))?;
|
||||||
let legacy: LegacyPersistedState = serde_json::from_str(&raw)
|
let legacy: LegacyPersistedState = serde_json::from_str(&raw)
|
||||||
.with_context(|| format!("failed to parse legacy state {}", configured_path.display()))?;
|
.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 {
|
for token in legacy.enroll_tokens {
|
||||||
self.enroll_tokens
|
self.enroll_tokens
|
||||||
.insert(token.as_bytes(), &[])
|
.insert(token.as_bytes(), &expires_at_unix.to_le_bytes())
|
||||||
.context("failed to migrate enroll token")?;
|
.context("failed to migrate enroll token")?;
|
||||||
}
|
}
|
||||||
for (agent_id, token) in legacy.agents {
|
for (agent_id, token) in legacy.agents {
|
||||||
@@ -177,7 +206,29 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.flush().context("failed flushing migrated legacy state")?;
|
self.flush().context("failed flushing migrated legacy state")?;
|
||||||
info!(legacy = %configured_path.display(), db = %self.db_path.display(), "migrated legacy json state into sled db");
|
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 mut removed = 0u64;
|
||||||
|
for item in self.enroll_tokens.iter() {
|
||||||
|
let (token, value) = item.context("failed iterating enroll token tree")?;
|
||||||
|
let expires_at = decode_expiry(value.as_ref()).context("failed decoding token expiry during gc")?;
|
||||||
|
if expires_at <= now {
|
||||||
|
self.enroll_tokens
|
||||||
|
.remove(token)
|
||||||
|
.context("failed removing expired enroll token")?;
|
||||||
|
removed = removed.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removed > 0 {
|
||||||
|
self.flush().context("failed flushing db after gc")?;
|
||||||
|
info!(removed, "garbage-collected expired enroll tokens");
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,3 +239,19 @@ fn canonical_db_path(configured: &Path) -> PathBuf {
|
|||||||
_ => configured.to_path_buf(),
|
_ => configured.to_path_buf(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn now_unix() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_expiry(raw: &[u8]) -> Result<u64> {
|
||||||
|
if raw.len() != 8 {
|
||||||
|
anyhow::bail!("invalid token expiry length {}", raw.len());
|
||||||
|
}
|
||||||
|
let mut arr = [0u8; 8];
|
||||||
|
arr.copy_from_slice(raw);
|
||||||
|
Ok(u64::from_le_bytes(arr))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user