Skip to content

Dev#71

Closed
vsilent wants to merge 10 commits intotrydirect:masterfrom
vsilent:dev
Closed

Dev#71
vsilent wants to merge 10 commits intotrydirect:masterfrom
vsilent:dev

Conversation

@vsilent
Copy link
Collaborator

@vsilent vsilent commented Mar 19, 2026

Registration/Auth from Status Panel web UI

Copilot AI review requested due to automatic review settings March 19, 2026 12:53
@vsilent vsilent closed this Mar 19, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds agent self-registration support so a Status Panel instance can register itself with the Stacker Server (via CLI and a local API endpoint), and persists the returned registration data for later use.

Changes:

  • Introduce a new status register CLI subcommand to register an agent and optionally save /etc/status-panel/registration.json.
  • Add POST /api/v1/register to the local Axum API to trigger agent self-registration from the web/UI or install flow.
  • Add agent::registration module to collect a server fingerprint, call the remote registration API, and save the registration result.

Reviewed changes

Copilot reviewed 20 out of 22 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/main.rs Adds Register CLI subcommand and wiring to registration module + persistence.
src/comms/local_api.rs Adds POST /api/v1/register route and handler to perform registration and save locally.
src/agent/registration.rs New module implementing fingerprint collection, remote registration request, and saving registration JSON.
src/agent/mod.rs Exposes the new registration module.
TODO.md Adds roadmap items for marketplace integration and registration flow.
README.md Refreshes project overview/docs and highlights capabilities.
Comments suppressed due to low confidence (4)

src/comms/local_api.rs:1584

  • The request payload allows an arbitrary server URL override, and the handler uses it directly to build an outbound reqwest request. Because this endpoint is unauthenticated, this becomes an SSRF primitive (POST to attacker-chosen host/port) and can also exfiltrate the machine fingerprint. Consider removing the override from the HTTP API (keep it for the CLI if needed), or strictly validate/allowlist the host + enforce https before making the request.

async fn commands_wait(
    State(state): State<SharedState>,
    Path(_hash): Path<String>,
    Query(params): Query<WaitParams>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(resp) = validate_agent_id(&headers) {
        return resp.into_response();
    }
    // Optional signing for GET /wait (empty body) controlled by env flag
    let require_sig = std::env::var("WAIT_REQUIRE_SIGNATURE")
        .map(|v| v == "true")
        .unwrap_or(false);
    if require_sig {
        if let Err(resp) = verify_stacker_post(&state, &headers, &[], "commands:wait").await {
            return resp.into_response();
        }
    } else {
        // Lightweight rate limiting without signature

src/comms/local_api.rs:1604

  • register_handler performs synchronous disk I/O via save_registration() from within an async Axum handler. This can block the Tokio runtime under load. Consider switching save_registration to tokio::fs (async) or calling the existing sync function inside tokio::task::spawn_blocking, and returning a warning in the response if persistence fails (so callers can detect partial success).
                headers
                    .get("X-Agent-Id")
                    .and_then(|v| v.to_str().ok())
                    .unwrap_or(""),
            )
            .await
        {
            state.audit.rate_limited(
                headers
                    .get("X-Agent-Id")
                    .and_then(|v| v.to_str().ok())
                    .unwrap_or(""),
                None,
            );
            return (
                StatusCode::TOO_MANY_REQUESTS,
                Json(json!({"error": "rate limited"})),

src/comms/local_api.rs:918

  • A new HTTP endpoint (POST /api/v1/register) is added, but there are existing route integration tests in tests/http_routes.rs covering most endpoints. Consider adding tests for this route (at least validating expected status codes and any access restrictions you implement, e.g., loopback-only / already-registered behavior), so regressions don’t silently expose the endpoint.
async fn capabilities_handler(State(state): State<SharedState>) -> impl IntoResponse {
    let compose_agent_env = std::env::var("COMPOSE_AGENT_ENABLED")
        .ok()
        .and_then(|v| v.parse::<bool>().ok());
    let compose_agent = compose_agent_env.unwrap_or(state.config.compose_agent_enabled);

src/comms/local_api.rs:918

  • POST /api/v1/register is exposed on the same router that binds to 0.0.0.0 (see serve()), but the handler currently has no authentication/authorization and no restriction to loopback clients. This allows any network-reachable caller to trigger registration attempts and overwrite /etc/status-panel/registration.json. Consider requiring an authenticated session (when with_ui), or at minimum rejecting non-loopback ConnectInfo/ClientIp and refusing to run if already registered unless an explicit force flag is provided.

async fn metrics_ws_stream(state: SharedState, mut socket: WebSocket) {
    let mut rx = state.metrics_tx.subscribe();

    // Send latest snapshot immediately
    let current = state.metrics_store.read().await.clone();
    if let Ok(text) = serde_json::to_string(&current) {
        let _ = socket.send(Message::Text(text.into())).await;
    }

    while let Ok(snapshot) = rx.recv().await {
        if let Ok(text) = serde_json::to_string(&snapshot) {
            if socket.send(Message::Text(text.into())).await.is_err() {
                break;
            }
        }
    }
}

async fn capabilities_handler(State(state): State<SharedState>) -> impl IntoResponse {
    let compose_agent_env = std::env::var("COMPOSE_AGENT_ENABLED")
        .ok()
        .and_then(|v| v.parse::<bool>().ok());
    let compose_agent = compose_agent_env.unwrap_or(state.config.compose_agent_enabled);

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +29 to +88
pub deployment_id: String,
pub stack_name: String,
pub status: String,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub server_ip: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct LinkAgentRequest {
pub session_token: String,
pub deployment_id: String,
pub server_fingerprint: ServerFingerprint,
}

#[derive(Debug, Serialize)]
pub struct ServerFingerprint {
pub hostname: String,
pub os: String,
pub cpu_count: u32,
pub ram_mb: u64,
pub disk_gb: u64,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RegistrationResponse {
pub agent_id: String,
pub agent_token: String,
pub deployment_hash: String,
pub dashboard_url: Option<String>,
}

/// Collect server fingerprint from the local system.
pub fn collect_fingerprint() -> ServerFingerprint {
let hostname = std::env::var("HOSTNAME")
.or_else(|_| std::fs::read_to_string("/etc/hostname").map(|s| s.trim().to_string()))
.unwrap_or_else(|_| "unknown".to_string());

let os = std::process::Command::new("uname")
.arg("-sr")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());

let cpu_count = std::fs::read_to_string("/proc/cpuinfo")
.map(|content| {
content
.lines()
.filter(|line| line.starts_with("processor"))
.count() as u32
})
.unwrap_or(1);

let ram_mb = std::fs::read_to_string("/proc/meminfo")
.ok()
.and_then(|content| {
content.lines().find_map(|line| {
Comment on lines +90 to +117
// Format: "MemTotal: 16384000 kB"
line.split_whitespace()
.nth(1)
.and_then(|v| v.parse::<u64>().ok())
.map(|kb| kb / 1024)
} else {
None
}
})
})
.unwrap_or(0);

let disk_gb = std::process::Command::new("df")
.args(["--output=size", "-BG", "/"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| {
s.lines()
.nth(1) // skip header
.and_then(|line| line.trim().trim_end_matches('G').parse::<u64>().ok())
})
.unwrap_or(0);

ServerFingerprint {
hostname,
os,
cpu_count,
Comment on lines +133 to +148
cpu_count = %fingerprint.cpu_count,
ram_mb = %fingerprint.ram_mb,
disk_gb = %fingerprint.disk_gb,
"collected server fingerprint"
);

let body = RegistrationRequest {
purchase_token: purchase_token.to_string(),
server_fingerprint: fingerprint,
stack_id: stack_id.to_string(),
};

let url = format!("{}/api/v1/agents/register", dashboard_url);
info!(url = %url, stack_id = %stack_id, "sending registration request to Stacker");

let client = reqwest::Client::new();
Comment on lines +156 to +161

let reg: RegistrationResponse = resp.json().await?;
info!(
agent_id = %reg.agent_id,
deployment_hash = %reg.deployment_hash,
"registration successful"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants