diff --git a/.env.example b/.env.example index fb8babe..635e977 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,10 @@ COMMAND_TIMEOUT_SECS=300 STATUS_PANEL_USERNAME=admin STATUS_PANEL_PASSWORD=admin +# HTTP bind address. Defaults to 127.0.0.1 (loopback) for bare-metal safety. +# In containers with port mapping, must be 0.0.0.0 to be reachable from the host. +STATUS_PANEL_BIND=0.0.0.0 + # Backup signer / verification DEPLOYMENT_HASH=replace-with-secret TRYDIRECT_IP=127.0.0.1 diff --git a/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json b/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json new file mode 100644 index 0000000..7deb31d --- /dev/null +++ b/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "selector": { + "mode": "remote", + "selector_kind": "app", + "selector": "status-panel-web", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "container": null + }, + "protocols_requested": [ + "html_forms" + ], + "capture_samples": true, + "cached_at": "2026-05-22T07:55:49.388248+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "project-status-panel-web-1", + "id": "form_contact", + "action": "", + "method": "POST", + "fields": [ + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "remote_app", + "selector": "status-panel-web", + "container": null, + "protocols": [ + "html_forms" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T07:55:48Z" + } +} \ No newline at end of file diff --git a/.stacker/pipe-scan-cache/abbfec54b2192c37e34aa64d9bdd8488b78a69d94f6735b7a874423724673f44.json b/.stacker/pipe-scan-cache/abbfec54b2192c37e34aa64d9bdd8488b78a69d94f6735b7a874423724673f44.json new file mode 100644 index 0000000..f3f9a79 --- /dev/null +++ b/.stacker/pipe-scan-cache/abbfec54b2192c37e34aa64d9bdd8488b78a69d94f6735b7a874423724673f44.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "selector": { + "mode": "remote", + "selector_kind": "app", + "selector": "status-panel-web", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T07:55:51.452961+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "project-status-panel-web-1", + "id": "form_contact", + "action": "", + "method": "POST", + "fields": [ + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "remote_app", + "selector": "status-panel-web", + "container": null, + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T07:55:50Z" + } +} \ No newline at end of file diff --git a/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json b/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json new file mode 100644 index 0000000..f3f9a79 --- /dev/null +++ b/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "selector": { + "mode": "remote", + "selector_kind": "app", + "selector": "status-panel-web", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T07:55:51.452961+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "project-status-panel-web-1", + "id": "form_contact", + "action": "", + "method": "POST", + "fields": [ + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "remote_app", + "selector": "status-panel-web", + "container": null, + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T07:55:50Z" + } +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 4aa2128..af0ef84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3724,7 +3724,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "status-panel" -version = "0.1.9" +version = "0.2.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 51afb29..4845b74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "status-panel" -version = "0.1.9" +version = "0.2.0" edition = "2021" [features] diff --git a/development.md.bak b/development.md.bak new file mode 100644 index 0000000..76db356 --- /dev/null +++ b/development.md.bak @@ -0,0 +1,21 @@ +## Docker buildx quick reference + +Use this to publish the same multi-platform image variants that CI builds for the +`dev` branch: + +```bash +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-context stacker=../stacker \ + -f Dockerfile.prod \ + -t trydirect/status:unstable \ + -t trydirect/status:latest \ + --push \ + . +``` + +This requires a sibling checkout at `../stacker` because `Cargo.toml` includes +local path dependencies from that repository. + +If you only want to validate the multi-platform build locally without pushing, +replace `--push` with `--output=type=oci,dest=./status-multiarch.tar`. diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 848d6dc..24cd5e3 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -2,14 +2,14 @@ version: '2.2' services: statuspanel: - image: status + image: trydirect/status:dev container_name: statuspanel ports: - "5000:5000" volumes: - - .:/app + #- .:/app - /var/run/docker.sock:/var/run/docker.sock - - /data/encrypted:/data/encrypted + #- /data/encrypted:/data/encrypted # Mount docker CLI from host for deploy_app/remove_app commands - /usr/bin/docker:/usr/bin/docker:ro - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro diff --git a/docker-compose.yml b/docker-compose.yml index 0d8ea9a..e4f6d85 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,8 @@ services: environment: - NGINX_CONTAINER=nginx - COMPOSE_AGENT_ENABLED=true + # Container port mapping requires binding all interfaces. + - STATUS_PANEL_BIND=0.0.0.0 # Default: serve with UI command: ["serve", "--port", "5000", "--with-ui"] diff --git a/src/security/audit_log.rs b/src/security/audit_log.rs index 68ca24b..f78c9dd 100644 --- a/src/security/audit_log.rs +++ b/src/security/audit_log.rs @@ -46,6 +46,10 @@ impl AuditLogger { info!(target: "audit", event = "token_rotated", agent_id, request_id = request_id.unwrap_or("")); } + pub fn token_self_issued(&self, deployment_hash: &str, reason: &str) { + warn!(target: "audit", event = "token_self_issued", deployment_hash, reason); + } + pub fn internal_error( &self, agent_id: Option<&str>, diff --git a/src/security/token_provider.rs b/src/security/token_provider.rs index 61f8bfc..609c991 100644 --- a/src/security/token_provider.rs +++ b/src/security/token_provider.rs @@ -108,6 +108,28 @@ impl TokenProvider { return Ok(true); } + // Strategy 3: self-issue a new token and write it to Vault. + // Stacker validates by reading from the same Vault KV path, so a token + // the agent writes there is immediately accepted on the next poll. + if let Some(vault) = &self.vault_client { + let new_token = generate_secure_token(); + match vault + .store_agent_token(&self.deployment_hash, &new_token, None) + .await + { + Ok(()) => { + let mut token = self.token.write().await; + *token = new_token; + super::audit_log::AuditLogger::new() + .token_self_issued(&self.deployment_hash, "primary_strategies_failed"); + return Ok(true); + } + Err(e) => { + warn!(error = %e, "Vault self-issue failed; token unchanged"); + } + } + } + debug!("No new token available after refresh attempt"); Ok(false) } @@ -121,6 +143,14 @@ impl TokenProvider { } } +fn generate_secure_token() -> String { + use ring::rand::{SecureRandom, SystemRandom}; + let rng = SystemRandom::new(); + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes).expect("CSPRNG failure"); + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + #[cfg(test)] mod tests { use super::*; @@ -190,4 +220,100 @@ mod tests { tp2.swap("b".into()).await; assert_eq!(tp.get().await, "b"); } + + #[tokio::test] + async fn refresh_self_issues_token_when_vault_has_no_entry() { + use crate::security::vault_client::VaultClient; + use mockito::Server; + + let _guard = env_lock().lock().unwrap(); + + let mut server = Server::new_async().await; + + // Strategy 1: Vault read returns 404 (KV entry was deleted) + let read = server + .mock("GET", "/v1/status_panel/dep-abc/status_panel_token") + .with_status(404) + .with_body(r#"{"errors":[]}"#) + .expect(1) + .create_async() + .await; + + // Strategy 3: Vault write accepted (must be called exactly once) + let write = server + .mock("POST", "/v1/status_panel/dep-abc/status_panel_token") + .with_status(200) + .with_body("{}") + .expect(1) + .create_async() + .await; + + let _addr = EnvGuard::set("VAULT_ADDRESS", &server.url()); + let _tok = EnvGuard::set("VAULT_TOKEN", "vault-root"); + let _prefix = EnvGuard::set("VAULT_AGENT_PATH_PREFIX", "status_panel"); + // Strategy 2 is a no-op: env token matches current + let _agent = EnvGuard::set("AGENT_TOKEN", "stale-token"); + + let vault = VaultClient::from_env().unwrap().unwrap(); + let tp = TokenProvider::new("stale-token".into(), Some(vault), "dep-abc".into()); + + let changed = tp.refresh().await.unwrap(); + assert!(changed, "expected self-issued token to be applied"); + + let new_token = tp.get().await; + assert_ne!(new_token, "stale-token"); + assert_eq!(new_token.len(), 64, "expected 32-byte hex token"); + assert!(new_token.chars().all(|c| c.is_ascii_hexdigit())); + + read.assert_async().await; + write.assert_async().await; + } + + #[tokio::test] + async fn refresh_keeps_token_when_vault_write_forbidden() { + use crate::security::vault_client::VaultClient; + use mockito::Server; + + let _guard = env_lock().lock().unwrap(); + + let mut server = Server::new_async().await; + + // Strategy 1: Vault read returns 404 + let _read = server + .mock("GET", "/v1/status_panel/dep-xyz/status_panel_token") + .with_status(404) + .with_body(r#"{"errors":[]}"#) + .create_async() + .await; + + // Strategy 3: Vault write rejected (agent lacks write capability) + let write = server + .mock("POST", "/v1/status_panel/dep-xyz/status_panel_token") + .with_status(403) + .with_body(r#"{"errors":["permission denied"]}"#) + .expect(1) + .create_async() + .await; + + let _addr = EnvGuard::set("VAULT_ADDRESS", &server.url()); + let _tok = EnvGuard::set("VAULT_TOKEN", "vault-root"); + let _prefix = EnvGuard::set("VAULT_AGENT_PATH_PREFIX", "status_panel"); + let _agent = EnvGuard::set("AGENT_TOKEN", "stale-token"); + + let vault = VaultClient::from_env().unwrap().unwrap(); + let tp = TokenProvider::new("stale-token".into(), Some(vault), "dep-xyz".into()); + + let changed = tp.refresh().await.unwrap(); + assert!( + !changed, + "Strategy 3 must not claim success on write failure" + ); + assert_eq!( + tp.get().await, + "stale-token", + "token must not change on failure" + ); + + write.assert_async().await; + } } diff --git a/src/security/vault_client.rs b/src/security/vault_client.rs index 505d4e8..5bdf798 100644 --- a/src/security/vault_client.rs +++ b/src/security/vault_client.rs @@ -49,7 +49,7 @@ use anyhow::{Context, Result}; use async_trait::async_trait; -use reqwest::{Client, StatusCode}; +use reqwest::{Client, Identity, StatusCode}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; @@ -369,13 +369,22 @@ trait VaultTransport: Send + Sync { #[derive(Debug, Default)] struct ReqwestVaultTransport; +impl ReqwestVaultTransport { + fn build_client() -> Result { + let mut builder = Client::builder().timeout(std::time::Duration::from_secs(10)); + + if let Some(identity) = load_mtls_identity() { + builder = builder.identity(identity); + } + + builder.build().context("creating HTTP client") + } +} + #[async_trait] impl VaultTransport for ReqwestVaultTransport { async fn get(&self, url: &str, token: &str) -> Result { - let response = Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .context("creating HTTP client")? + let response = Self::build_client()? .get(url) .header("X-Vault-Token", token) .send() @@ -393,10 +402,7 @@ impl VaultTransport for ReqwestVaultTransport { token: &str, payload: &serde_json::Value, ) -> Result { - let response = Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .context("creating HTTP client")? + let response = Self::build_client()? .post(url) .header("X-Vault-Token", token) .json(payload) @@ -410,6 +416,37 @@ impl VaultTransport for ReqwestVaultTransport { } } +/// Load mTLS client certificate identity from environment variables. +/// +/// Supports two modes: +/// 1. Inline PEM via `VAULT_CLIENT_CERT` and `VAULT_CLIENT_KEY` env vars +/// 2. File paths via `VAULT_CLIENT_CERT_PATH` and `VAULT_CLIENT_KEY_PATH` env vars +/// +/// Returns `None` if neither set (mTLS is optional — degradation path). +fn load_mtls_identity() -> Option { + let cert_pem = std::env::var("VAULT_CLIENT_CERT").ok().or_else(|| { + let path = std::env::var("VAULT_CLIENT_CERT_PATH").ok()?; + std::fs::read_to_string(path).ok() + })?; + + let key_pem = std::env::var("VAULT_CLIENT_KEY").ok().or_else(|| { + let path = std::env::var("VAULT_CLIENT_KEY_PATH").ok()?; + std::fs::read_to_string(path).ok() + })?; + + let identity_pem = format!("{}\n{}", cert_pem, key_pem); + match Identity::from_pem(identity_pem.as_bytes()) { + Ok(identity) => { + debug!("mTLS client identity loaded for Vault connections"); + Some(identity) + } + Err(e) => { + warn!("Failed to load mTLS client identity: {}", e); + None + } + } +} + // ============================================================================= // Vault Client Implementation // ============================================================================= @@ -495,10 +532,14 @@ impl VaultClient { // Configure HTTP client with security-conscious defaults: // - 10 second timeout prevents resource exhaustion from hanging connections // - TLS certificate validation enabled by default (reqwest behavior) - let http_client = Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .context("creating HTTP client")?; + // - mTLS client identity loaded from VAULT_CLIENT_CERT / VAULT_CLIENT_KEY + let mut builder = Client::builder().timeout(std::time::Duration::from_secs(10)); + + if let Some(identity) = load_mtls_identity() { + builder = builder.identity(identity); + } + + let http_client = builder.build().context("creating HTTP client")?; // Note: We log the base_url but NEVER log the token debug!("Vault client initialized with base_url={}", base); diff --git a/stacker/stacker/src/cli/stacker_client.rs b/stacker/stacker/src/cli/stacker_client.rs index 8359f8c..fa6c46e 100644 --- a/stacker/stacker/src/cli/stacker_client.rs +++ b/stacker/stacker/src/cli/stacker_client.rs @@ -25,7 +25,7 @@ pub const DEFAULT_STACKER_URL: &str = "https://stacker.try.direct"; /// The Install Service Ansible role uses this to configure the agent's VAULT_ADDRESS /// environment variable on the remote server. Must be a publicly reachable address /// (not a Docker-internal IP) so deployed agents can connect to Vault. -pub const DEFAULT_VAULT_URL: &str = "https://vault.try.direct"; +pub const DEFAULT_VAULT_URL: &str = "https://vault.try.direct:8443"; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Response types (matching Stacker server JSON envelope) diff --git a/web/.stacker/.gitignore b/web/.stacker/.gitignore new file mode 100644 index 0000000..9643030 --- /dev/null +++ b/web/.stacker/.gitignore @@ -0,0 +1,4 @@ +# Deployment lock files contain environment-specific metadata (server IPs, emails, etc.) +# and should not be committed to source control +deployment-*.lock +deployment-*.lock.back diff --git a/web/.stacker/Dockerfile b/web/.stacker/Dockerfile new file mode 100644 index 0000000..57986f9 --- /dev/null +++ b/web/.stacker/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +WORKDIR /app + +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY package*.json ./ +COPY . . + +RUN npm ci +RUN npm run build + +EXPOSE 3000 + +CMD ["npm", "run", "start"] diff --git a/web/.stacker/active-target b/web/.stacker/active-target new file mode 100644 index 0000000..ac2564f --- /dev/null +++ b/web/.stacker/active-target @@ -0,0 +1 @@ +cloud \ No newline at end of file diff --git a/web/.stacker/deploy/production/config-bundle.manifest.json b/web/.stacker/deploy/production/config-bundle.manifest.json new file mode 100644 index 0000000..43942e1 --- /dev/null +++ b/web/.stacker/deploy/production/config-bundle.manifest.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "environment": "production", + "files": [ + { + "source_path": ".env", + "destination_path": ".env", + "mode": "0644", + "size": 215, + "sha256": "fac5fd4c950c34233bcf7d4ad1750539c2234290846630e4e6edd35029a44bc6" + } + ] +} \ No newline at end of file diff --git a/web/.stacker/deploy/production/config-bundle.tar.zst b/web/.stacker/deploy/production/config-bundle.tar.zst new file mode 100644 index 0000000..b4c25b3 Binary files /dev/null and b/web/.stacker/deploy/production/config-bundle.tar.zst differ diff --git a/web/.stacker/deploy/production/docker-compose.remote.yml b/web/.stacker/deploy/production/docker-compose.remote.yml new file mode 100644 index 0000000..b80022d --- /dev/null +++ b/web/.stacker/deploy/production/docker-compose.remote.yml @@ -0,0 +1,26 @@ +services: + status-panel-web: + build: + context: . + dockerfile: Dockerfile + image: trydirect/status-panel-web:latest + container_name: status-panel-web + ports: + - "3000:3000" + env_file: + - .env + environment: + NODE_ENV: production + NEXT_PUBLIC_SITE_URL: https://status.stacker.my + restart: unless-stopped + smtp: + image: trydirect/smtp + ports: + - "1025:1025" + - "8025:8025" + volumes: + - smtp_data:/data + restart: unless-stopped +volumes: + smtp_data: + name: smtp_data diff --git a/web/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json b/web/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json new file mode 100644 index 0000000..4b5e622 --- /dev/null +++ b/web/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "selector": { + "mode": "remote", + "selector_kind": "app", + "selector": "status-panel-web", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "container": null + }, + "protocols_requested": [ + "html_forms" + ], + "capture_samples": true, + "cached_at": "2026-05-21T17:52:06.693176+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "project-status-panel-web-1", + "id": "form_contact", + "action": "", + "method": "POST", + "fields": [ + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "remote_app", + "selector": "status-panel-web", + "container": null, + "protocols": [ + "html_forms" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-21T17:52:06Z" + } +} \ No newline at end of file diff --git a/web/.stacker/pipe-scan-cache/93b072a1bd79a89f53adfb683923a455cb462df04e62db8c92bfdd7d53dc53b3.json b/web/.stacker/pipe-scan-cache/93b072a1bd79a89f53adfb683923a455cb462df04e62db8c92bfdd7d53dc53b3.json new file mode 100644 index 0000000..dabf80f --- /dev/null +++ b/web/.stacker/pipe-scan-cache/93b072a1bd79a89f53adfb683923a455cb462df04e62db8c92bfdd7d53dc53b3.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "selector": { + "mode": "local", + "selector_kind": "containers", + "selector": "status-panel-web2", + "deployment_hash": null, + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T09:21:38.787954+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "status-panel-web2", + "protocols_detected": [], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [], + "probe_attempts": [ + { + "scope": "local_selector", + "selector": "status-panel-web2", + "container": null, + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "empty" + } + ], + "target_kind": "unknown", + "probed_at": "2026-05-22T09:21:38.787581+00:00" + } +} \ No newline at end of file diff --git a/web/.stacker/pipe-scan-cache/fde369cb41907befb08176c9a38443049b23994e2c8a523c95f9a6ceb632840e.json b/web/.stacker/pipe-scan-cache/fde369cb41907befb08176c9a38443049b23994e2c8a523c95f9a6ceb632840e.json new file mode 100644 index 0000000..34fea0a --- /dev/null +++ b/web/.stacker/pipe-scan-cache/fde369cb41907befb08176c9a38443049b23994e2c8a523c95f9a6ceb632840e.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "selector": { + "mode": "local", + "selector_kind": "containers", + "selector": "status-panel-web", + "deployment_hash": null, + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T08:17:44.994249+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [ + { + "name": "status-panel-web", + "image": "trydirect/status-panel-web:latest", + "network": "web_default", + "ports": [ + "3000->3000/tcp", + "3000/tcp" + ], + "addresses": [ + "172.20.0.3:3000", + "172.20.0.3:3000" + ] + } + ], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "status-panel-web", + "id": "status-panel-web/contact", + "action": "/contact", + "method": "POST", + "fields": [ + "$ACTION_REF_1", + "$ACTION_1:0", + "$ACTION_1:1", + "$ACTION_KEY", + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "local_container", + "selector": "status-panel-web", + "container": "status-panel-web", + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T08:17:44.990008+00:00" + } +} \ No newline at end of file diff --git a/web/.stacker/pipe-scan-cache/latest-18c84187ae0a8a25aba883fba3f0136c2ca24a8f4a7b416c9628be021b93b7a3.json b/web/.stacker/pipe-scan-cache/latest-18c84187ae0a8a25aba883fba3f0136c2ca24a8f4a7b416c9628be021b93b7a3.json new file mode 100644 index 0000000..34fea0a --- /dev/null +++ b/web/.stacker/pipe-scan-cache/latest-18c84187ae0a8a25aba883fba3f0136c2ca24a8f4a7b416c9628be021b93b7a3.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "selector": { + "mode": "local", + "selector_kind": "containers", + "selector": "status-panel-web", + "deployment_hash": null, + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T08:17:44.994249+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [ + { + "name": "status-panel-web", + "image": "trydirect/status-panel-web:latest", + "network": "web_default", + "ports": [ + "3000->3000/tcp", + "3000/tcp" + ], + "addresses": [ + "172.20.0.3:3000", + "172.20.0.3:3000" + ] + } + ], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "status-panel-web", + "id": "status-panel-web/contact", + "action": "/contact", + "method": "POST", + "fields": [ + "$ACTION_REF_1", + "$ACTION_1:0", + "$ACTION_1:1", + "$ACTION_KEY", + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "local_container", + "selector": "status-panel-web", + "container": "status-panel-web", + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-22T08:17:44.990008+00:00" + } +} \ No newline at end of file diff --git a/web/.stacker/pipe-scan-cache/latest-b436c2a84c88a5d6edb9c31ced9c7eb8f3d5704623b89cfb49eb82069e81bd25.json b/web/.stacker/pipe-scan-cache/latest-b436c2a84c88a5d6edb9c31ced9c7eb8f3d5704623b89cfb49eb82069e81bd25.json new file mode 100644 index 0000000..dabf80f --- /dev/null +++ b/web/.stacker/pipe-scan-cache/latest-b436c2a84c88a5d6edb9c31ced9c7eb8f3d5704623b89cfb49eb82069e81bd25.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "selector": { + "mode": "local", + "selector_kind": "containers", + "selector": "status-panel-web2", + "deployment_hash": null, + "container": null + }, + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "capture_samples": true, + "cached_at": "2026-05-22T09:21:38.787954+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "local", + "app_code": "status-panel-web2", + "protocols_detected": [], + "protocols_requested": [ + "html_forms", + "openapi", + "rest" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [], + "probe_attempts": [ + { + "scope": "local_selector", + "selector": "status-panel-web2", + "container": null, + "protocols": [ + "html_forms", + "openapi", + "rest" + ], + "outcome": "empty" + } + ], + "target_kind": "unknown", + "probed_at": "2026-05-22T09:21:38.787581+00:00" + } +} \ No newline at end of file diff --git a/web/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json b/web/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json new file mode 100644 index 0000000..4b5e622 --- /dev/null +++ b/web/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "selector": { + "mode": "remote", + "selector_kind": "app", + "selector": "status-panel-web", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "container": null + }, + "protocols_requested": [ + "html_forms" + ], + "capture_samples": true, + "cached_at": "2026-05-21T17:52:06.693176+00:00", + "report": { + "type": "probe_endpoints", + "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c", + "app_code": "status-panel-web", + "protocols_detected": [ + "html_forms" + ], + "protocols_requested": [ + "html_forms" + ], + "containers": [], + "endpoints": [], + "resources": [], + "forms": [ + { + "container": "project-status-panel-web-1", + "id": "form_contact", + "action": "", + "method": "POST", + "fields": [ + "name", + "email", + "subject", + "message" + ] + } + ], + "probe_attempts": [ + { + "scope": "remote_app", + "selector": "status-panel-web", + "container": null, + "protocols": [ + "html_forms" + ], + "outcome": "detected" + } + ], + "target_kind": "html_form", + "probed_at": "2026-05-21T17:52:06Z" + } +} \ No newline at end of file diff --git a/web/.stacker/pipes/status-panel-web-to-smtp-3.json b/web/.stacker/pipes/status-panel-web-to-smtp-3.json new file mode 100644 index 0000000..5e1212d --- /dev/null +++ b/web/.stacker/pipes/status-panel-web-to-smtp-3.json @@ -0,0 +1,98 @@ +{ + "schema_version": 1, + "id": "status-panel-web-to-smtp-3", + "name": "status-panel-web-to-smtp-3", + "created_at": "2026-05-22T09:24:50.473437+00:00", + "updated_at": "2026-05-23T10:51:20.716349+00:00", + "status": "active", + "source": { + "selector": "status-panel-web", + "container": "status-panel-web", + "method": "POST", + "path": "/contact", + "fields": [ + "$ACTION_REF_1", + "$ACTION_1:0", + "$ACTION_1:1", + "$ACTION_KEY", + "name", + "email", + "subject", + "message" + ] + }, + "target": { + "selector": "smtp", + "adapter": { + "code": "smtp", + "role": "target", + "config": { + "from": "info@stacker.my", + "host": "smtp", + "port": 25, + "tls": false, + "to": [ + "info@optimum-web.com" + ] + } + }, + "method": "SEND", + "path": "adapter:smtp", + "fields": [ + "from_email", + "reply_to_email", + "subject", + "body_text", + "body_html" + ] + }, + "template": { + "description": "POST /contact → SEND adapter:smtp", + "source_app_type": "status-panel-web", + "source_endpoint": { + "method": "POST", + "path": "/contact" + }, + "target_app_type": "smtp", + "target_endpoint": { + "adapter": "smtp", + "display_name": "SMTP target", + "mode": "adapter" + }, + "field_mapping": {}, + "config": { + "retry_count": 3 + }, + "is_public": false + }, + "instance": { + "source_container": "status-panel-web", + "target_adapter": { + "code": "smtp", + "role": "target", + "config": { + "from": "info@stacker.my", + "host": "smtp", + "port": 25, + "tls": false, + "to": [ + "info@optimum-web.com" + ] + } + }, + "trigger_count": 1, + "error_count": 11, + "last_triggered_at": "2026-05-22T10:16:28.090543+00:00" + }, + "promotion": { + "last_deployment_hash": "deployment_d6804c40-2ace-4dd5-bbfe-428cce3c50f8", + "remote_template_id": "a36e2837-ec76-4d5e-ba27-1f4736d4cc89", + "remote_instance_id": "4922167c-7cb7-45c1-9c2b-6207c936d9bc", + "promoted_at": "2026-05-23T10:51:20.716349+00:00" + }, + "diagnostics": { + "notes": [ + "source discovery: cached result (protocols: html_forms,openapi,rest, capture_samples: true)" + ] + } +} \ No newline at end of file diff --git a/web/TODO.md b/web/TODO.md new file mode 100644 index 0000000..3e4bf7b --- /dev/null +++ b/web/TODO.md @@ -0,0 +1,75 @@ +# TODO + +## MCP coverage gaps from the Status website scenario + +Use this table to complete MCP parity and then re-check the full onboarding +scenario end to end. + +| Scenario step | MCP coverage | +|---|---| +| `npm install`, `npm run build` | No - local workstation command, outside Stacker MCP | +| `docker build`, `docker run`, local health check | No - local Docker/runtime validation | +| `stacker login` | No - authentication/bootstrap flow, not an MCP tool | +| `stacker init` generating `stacker.yml` and `.stacker/Dockerfile` | No direct equivalent | +| `.env` bootstrap from `.env.example` | Gap - no MCP/local-bootstrap tool | +| `stacker config setup ai` | Gap - no MCP tool for local AI config setup | +| Parse/discover compose services | Yes - `discover_stack_services` | +| Create project/app records | Yes - `create_project`, `create_project_app` | +| Validate server-side stack config | Partial - `validate_stack_config`, but not the same as local `stacker config validate` | +| `stacker config fix`, `show`, `inventory`, `diff`, `check`, `promote` | Gap - no MCP parity for local config workflows | +| Deploy project/app | Yes - `start_deployment`, `initiate_deployment`, `deploy_app`, deployment plan tools | +| Paused deploy troubleshooting | Yes - `diagnose_deployment` now returns MCP tool sequence, safe AI context rules, and `stacker-cli` recovery commands | +| Agent status | Yes - `get_agent_status` | +| Logs/health/containers | Yes - `get_container_logs`, `get_container_health`, `list_containers` | +| Proxy/NPM setup | Yes - `configure_proxy`, `configure_proxy_agent`, `list_proxies`, `delete_proxy` | +| Remote service secrets | Yes - `list_remote_secret_targets`, `set_remote_service_secret`, and related remote secret tools | +| Private registry auth setup | Gap - no MCP/local workflow for deploy registry credentials | +| Cloud provider firewall commands | Partial/gap - MCP has target/server firewall tools, but not the same cloud-provider firewall list/add flow | +| `stacker agent install` / managed runtime refresh | Gap - no MCP tool found for agent install/refresh | +| Pipes | Gap - no MCP tools found for `pipe scan/create/activate/trigger/history` | + +## Follow-up MCP work + +1. Add a local/bootstrap MCP or companion workflow for `stacker init` parity. +2. Add MCP parity for local config workflows: inventory, diff, promote, check, + and local `stacker config validate`. +3. Add cloud-provider firewall MCP tools that match `stacker cloud firewall` + list/add behavior. +4. Add an agent install/refresh MCP tool for the Status Panel and managed + runtime features. +5. Add pipe management MCP tools for scan, create, activate, trigger, and + history. +6. Re-run the Status website onboarding story and update this table after each + gap is closed. + +## Stacker onboarding UX gaps found during the walkthrough + +These are places where the walkthrough required manual editing or manual +diagnosis that Stacker should handle directly. + +| Gap | Better Stacker behavior | +|---|---| +| Missing `.env` required by `docker-compose.yml` | `stacker init` or `stacker deploy` should detect `env_file: .env`, offer to create `.env` from `.env.example`, and apply safe permissions | +| `--key` / `--key-id` still entered the cloud-selection path when `deploy.cloud` was missing | CLI cloud overrides should populate cloud config in memory before any prompt or remote lookup | +| Generated config had nullable structural fields | `stacker init` should emit compact, validation-clean YAML by default | +| Existing config still has nullable structural fields | `stacker config fix` should remove null structural fields without hand-editing YAML | +| Private image registry auth required explanation | `stacker deploy` should detect likely private-image pull risk and prompt for registry auth source or show exact env/config options | +| AI configuration required manual YAML editing | Add `stacker config setup ai` or `stacker ai configure --provider ollama --endpoint ... --model ...` | +| User-facing API errors exposed raw route/body details | Hide endpoints and raw bodies by default; show details only with `DEBUG=true`, `STACKER_DEBUG=true`, or `RUST_LOG=debug` | + +## Completed Stacker fixes during this walkthrough + +| Fix | Status | +|---|---| +| Compact `stacker init` output for future generated configs | Implemented in Stacker repo by background agent | +| Debug-gated Stacker API route/body errors | Implemented in Stacker repo by background agent | +| Missing `.env` referenced by compose/config | Implemented in Stacker repo: deploy copies from `.env.example` with restrictive permissions or returns actionable guidance | +| `--key` / `--key-id` cloud deploy overrides | Implemented in Stacker repo: resolved through the logged-in Stacker API before prompt selection | +| Non-interactive cloud selection | Implemented in Stacker repo: skips hanging prompts and tells the user to pass `--key`, `--key-id`, or configure cloud defaults | +| Existing config with nullable structural fields | Implemented in Stacker repo: validation suggests `stacker config fix`; fix removes empty structural path fields | +| Private image registry auth guidance | Implemented in Stacker repo: deploy prints concise credential guidance when needed | +| AI configuration without manual YAML edits | Implemented in Stacker repo: `stacker config setup ai` | +| Hetzner location/datacenter mismatch during cloud provisioning | Implemented in Stacker repo: deploy preserves the requested size and normalizes Hetzner locations such as `nbg1` before publishing installer payloads | +| Remote `.env` path mismatch during cloud install | Implemented in Stacker repo: config bundles now keep compose file references project-relative so copied files and Docker Compose paths match | +| Remote `.env` not materialized by installer | Implemented in Stacker repo: deploy-time config files are mirrored into installer runtime-file metadata before deployment | +| Paused deployment recovery path was tribal knowledge | Documented in `docs/recover-paused-deployment.md`: inspect status, use backup SSH key, classify failure, apply temporary fixes, ask AI safely, and redeploy | diff --git a/web/docker-compose.yml.bak b/web/docker-compose.yml.bak new file mode 100644 index 0000000..26e2a39 --- /dev/null +++ b/web/docker-compose.yml.bak @@ -0,0 +1,15 @@ +services: + status-panel-web: + build: + context: . + dockerfile: Dockerfile + image: trydirect/status-panel-web:latest + container_name: status-panel-web + ports: + - "3000:3000" + env_file: + - .env + environment: + NODE_ENV: production + NEXT_PUBLIC_SITE_URL: https://status.stacker.my + restart: unless-stopped diff --git a/web/stacker.yml.bak b/web/stacker.yml.bak new file mode 100644 index 0000000..49a195f --- /dev/null +++ b/web/stacker.yml.bak @@ -0,0 +1,84 @@ +name: web +version: 0.1.0 +organization: null +project: + identity: web +app: + type: node + path: . + dockerfile: null + image: null + build: null + ports: [] + volumes: [] + environment: {} +services: +- name: status-panel-web + image: trydirect/status-panel-web:0.1.0 + ports: + - 3000:3000 + environment: + NEXT_PUBLIC_SITE_URL: https://status.stacker.my + NODE_ENV: production + volumes: [] + depends_on: [] +- name: smtp + image: trydirect/smtp + ports: + - 127.0.0.1:1025:25 + - 8025:8025 + environment: {} + volumes: + - smtp_data:/data + depends_on: [] +proxy: + type: nginx-proxy-manager + auto_detect: false + domains: + - domain: status.stacker.my + ssl: auto + upstream: status-panel-web:3000 + config: null +deploy: + target: cloud + environment: null + compose_file: docker-compose.yml + deployment_hash: null + cloud: + provider: hetzner + orchestrator: remote + region: nbg1 + size: cx23 + install_image: null + remote_payload_file: null + ssh_key: ~/.ssh/id_rsa + key: null + server: null + server: null + registry: null + default_target: null + targets: {} +environments: {} +ai: + enabled: true + provider: ollama + model: qwen2.5-coder + api_key: null + endpoint: http://192.168.100.245:11434 + timeout: 0 + tasks: + - compose + - troubleshoot + - security +monitoring: + status_panel: true + healthcheck: null + metrics: null +hooks: + pre_build: null + post_deploy: null + on_failure: null +env_file: null +env: {} +config_contract: + services: {}