From 03a6427729a1a99699caabf04bedcca0d7c07c14 Mon Sep 17 00:00:00 2001 From: irving ou Date: Tue, 7 Apr 2026 17:04:46 -0400 Subject: [PATCH] feat: auth, enrollment API, and webapp for agent tunnel Scope token exchange, enrollment string generation, and the Angular webapp for agent management and tunnel-aware session creation. - Enrollment string endpoint (POST /enrollment-string) - Scope token exchange for agent management API - QUIC endpoint override from enrollment string - Agent enrollment UI (Agents page, enrollment form) - Agent selector control in connection forms - Auth interceptor fix (skip requests with existing Authorization) Co-Authored-By: Claude Opus 4.6 (1M context) --- devolutions-agent/src/enrollment.rs | 13 +- devolutions-agent/src/main.rs | 8 + .../src/api/agent_enrollment.rs | 4 + devolutions-gateway/src/api/webapp.rs | 164 +++++- devolutions-gateway/src/extract.rs | 5 +- webapp/apps/gateway-ui/proxy.conf.json | 24 +- .../src/client/app/app-auth.interceptor.ts | 3 + .../modules/base/menu/app-menu.component.html | 3 +- .../modules/base/menu/app-menu.component.ts | 12 + .../agent-enrollment.component.html | 158 ++++++ .../agent-enrollment.component.scss | 527 ++++++++++++++++++ .../agent-enrollment.component.ts | 138 +++++ .../ard/web-client-ard.component.ts | 1 + .../ard/ard-form.component.html | 5 + .../rdp/rdp-form.component.html | 5 + .../ssh/ssh-form.component.html | 5 + .../vnc/vnc-form.component.html | 5 + .../agent-selector-control.component.html | 19 + .../agent-selector-control.component.scss | 3 + .../agent-selector-control.component.ts | 57 ++ .../form/web-client-form.component.html | 6 +- .../form/web-client-form.component.ts | 4 + .../rdp/web-client-rdp.component.ts | 1 + .../ssh/web-client-ssh.component.ts | 1 + .../telnet/web-client-telnet.component.ts | 1 + .../vnc/web-client-vnc.component.ts | 1 + .../modules/web-client/web-client.module.ts | 8 + .../app/shared/interfaces/agent.interfaces.ts | 29 + .../connection-params.interfaces.ts | 6 + .../client/app/shared/services/api.service.ts | 54 +- .../app/shared/services/web-client.service.ts | 1 + 31 files changed, 1256 insertions(+), 15 deletions(-) create mode 100644 webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.html create mode 100644 webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.scss create mode 100644 webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.ts create mode 100644 webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.html create mode 100644 webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.scss create mode 100644 webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.ts create mode 100644 webapp/apps/gateway-ui/src/client/app/shared/interfaces/agent.interfaces.ts diff --git a/devolutions-agent/src/enrollment.rs b/devolutions-agent/src/enrollment.rs index 98d1a9a3a..671d37d43 100644 --- a/devolutions-agent/src/enrollment.rs +++ b/devolutions-agent/src/enrollment.rs @@ -52,7 +52,7 @@ pub async fn enroll_agent( agent_name: &str, advertise_subnets: Vec, ) -> anyhow::Result<()> { - bootstrap_and_persist(gateway_url, enrollment_token, agent_name, advertise_subnets).await?; + bootstrap_and_persist(gateway_url, enrollment_token, agent_name, advertise_subnets, None).await?; Ok(()) } @@ -61,11 +61,20 @@ pub async fn bootstrap_and_persist( enrollment_token: &str, agent_name: &str, advertise_subnets: Vec, + quic_endpoint_override: Option, ) -> anyhow::Result { // Generate key pair and CSR locally — the private key never leaves this machine. let (key_pem, csr_pem) = generate_key_and_csr(agent_name)?; - let enroll_response = request_enrollment(gateway_url, enrollment_token, agent_name, &csr_pem).await?; + let mut enroll_response = request_enrollment(gateway_url, enrollment_token, agent_name, &csr_pem).await?; + + // Prefer the QUIC endpoint from the enrollment string (set by the admin who knows + // the reachable address) over the enroll API response (which uses conf.hostname, + // often a container ID in Docker). + if let Some(endpoint) = quic_endpoint_override { + enroll_response.quic_endpoint = endpoint; + } + persist_enrollment_response(advertise_subnets, enroll_response, &key_pem) } diff --git a/devolutions-agent/src/main.rs b/devolutions-agent/src/main.rs index b2796e0d3..c32b03017 100644 --- a/devolutions-agent/src/main.rs +++ b/devolutions-agent/src/main.rs @@ -60,6 +60,7 @@ struct UpCommand { enrollment_token: String, agent_name: String, advertise_subnets: Vec, + quic_endpoint_override: Option, } #[derive(Debug, serde::Deserialize)] @@ -69,6 +70,8 @@ struct EnrollmentStringPayload { enrollment_token: String, #[serde(default)] name: Option, + #[serde(default)] + quic_endpoint: Option, } fn agent_service_main( @@ -199,11 +202,14 @@ fn parse_up_command_args(args: &[String]) -> Result { index += 1; } + let mut quic_endpoint_override = None; + if let Some(enrollment_string) = enrollment_string { let payload = parse_enrollment_string(&enrollment_string)?; gateway_url.get_or_insert(payload.api_base_url); enrollment_token.get_or_insert(payload.enrollment_token); + quic_endpoint_override = payload.quic_endpoint; if agent_name.is_none() { agent_name = payload.name; @@ -215,6 +221,7 @@ fn parse_up_command_args(args: &[String]) -> Result { enrollment_token: enrollment_token.context("missing required --token")?, agent_name: agent_name.context("missing required --name")?, advertise_subnets, + quic_endpoint_override, }) } @@ -306,6 +313,7 @@ fn main() { &command.enrollment_token, &command.agent_name, command.advertise_subnets, + command.quic_endpoint_override, ) .await }); diff --git a/devolutions-gateway/src/api/agent_enrollment.rs b/devolutions-gateway/src/api/agent_enrollment.rs index f65f6dc43..de5edfbdd 100644 --- a/devolutions-gateway/src/api/agent_enrollment.rs +++ b/devolutions-gateway/src/api/agent_enrollment.rs @@ -50,6 +50,10 @@ pub fn make_router(state: DgwState) -> Router { .route("/agents", axum::routing::get(list_agents)) .route("/agents/{agent_id}", axum::routing::get(get_agent).delete(delete_agent)) .route("/agents/resolve-target", axum::routing::post(resolve_target)) + .route( + "/enrollment-string", + axum::routing::post(super::webapp::create_agent_enrollment_string), + ) .with_state(state) } diff --git a/devolutions-gateway/src/api/webapp.rs b/devolutions-gateway/src/api/webapp.rs index f266f4207..e454fdf8a 100644 --- a/devolutions-gateway/src/api/webapp.rs +++ b/devolutions-gateway/src/api/webapp.rs @@ -29,6 +29,7 @@ pub fn make_router(state: DgwState) -> Router { .route("/client/{*path}", get(get_client)) .route("/app-token", post(sign_app_token)) .route("/session-token", post(sign_session_token)) + .route("/agent-management-token", post(sign_agent_management_token)) } else { Router::new() } @@ -232,6 +233,9 @@ pub(crate) enum SessionTokenContentType { destination: TargetAddr, /// Unique ID for this session session_id: Uuid, + /// Optional agent ID for routing through an enrolled agent tunnel. + #[serde(default)] + agent_id: Option, }, Jmux { /// Protocol for the session (e.g.: "tunnel") @@ -328,6 +332,7 @@ pub(crate) async fn sign_session_token( protocol, destination, session_id, + agent_id, } => ( AssociationTokenClaims { jet_aid: session_id, @@ -342,7 +347,7 @@ pub(crate) async fn sign_session_token( exp, jti, cert_thumb256: None, - jet_agent_id: None, + jet_agent_id: agent_id, } .pipe(serde_json::to_value) .map(|mut claims| { @@ -456,6 +461,63 @@ pub(crate) async fn sign_session_token( Ok(response) } +/// Exchange a WebApp token for an agent management scope token. +/// +/// This mirrors the DVLS pattern: DVLS signs scope tokens with its RSA key, +/// while the standalone webapp exchanges its WebApp token for a scope token here. +/// Both paths produce the same token type, so agent tunnel endpoints have +/// a single auth model (scope tokens only). +async fn sign_agent_management_token( + State(DgwState { conf_handle, .. }): State, + WebAppToken(web_app_token): WebAppToken, +) -> Result { + use picky::jose::jws::JwsAlg; + use picky::jose::jwt::CheckedJwtSig; + + use crate::token::{AccessScope, ScopeTokenClaims}; + + const LIFETIME_SECS: i64 = 300; // 5 minutes, same as DVLS scope tokens + + let conf = conf_handle.get_conf(); + + let provisioner_key = conf + .provisioner_private_key + .as_ref() + .ok_or_else(|| HttpError::internal().msg("provisioner private key is missing"))?; + + ensure_enabled(&conf)?; + + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + + let claims = ScopeTokenClaims { + scope: AccessScope::ConfigWrite, + exp: now + LIFETIME_SECS, + jti: Uuid::new_v4(), + } + .pipe(serde_json::to_value) + .map(|mut claims| { + if let Some(claims) = claims.as_object_mut() { + claims.insert("iat".to_owned(), serde_json::json!(now)); + claims.insert("nbf".to_owned(), serde_json::json!(now)); + } + claims + }) + .map_err(HttpError::internal().with_msg("scope claims").err())?; + + let jwt_sig = CheckedJwtSig::new_with_cty(JwsAlg::RS256, "SCOPE".to_owned(), claims); + + let token = jwt_sig + .encode(provisioner_key) + .map_err(HttpError::internal().with_msg("sign agent management token").err())?; + + info!(user = web_app_token.sub, "Granted agent management scope token"); + + let cache_control = TypedHeader(headers::CacheControl::new().with_no_cache().with_no_store()); + let response = (cache_control, token).into_response(); + + Ok(response) +} + async fn get_client( State(DgwState { conf_handle, .. }): State, path: Option>, @@ -504,6 +566,106 @@ fn ensure_enabled(conf: &crate::config::Conf) -> Result<(), HttpError> { extract_conf(conf).map(|_| ()) } +// -- Agent enrollment string generation -- // + +#[derive(Debug, Deserialize)] +pub(crate) struct AgentEnrollmentStringRequest { + /// Base URL for the gateway API (e.g. `https://gateway.example.com`). + api_base_url: String, + /// Optional QUIC host override. Defaults to the gateway hostname. + quic_host: Option, + /// Optional agent name hint. + name: Option, + /// Token lifetime in seconds (default: 3600). + lifetime: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct AgentEnrollmentStringResponse { + enrollment_string: String, + enrollment_command: String, + quic_endpoint: String, + expires_at_unix: u64, +} + +/// Generate a one-time enrollment string for agent enrollment. +/// +/// Accepts scope tokens with `ConfigWrite` scope only. Both the standalone +/// webapp (via `/jet/webapp/agent-management-token` exchange) and DVLS +/// (via direct RSA-signed scope tokens) produce the same token type. +pub(crate) async fn create_agent_enrollment_string( + State(DgwState { + conf_handle, + agent_tunnel_handle, + .. + }): State, + _access: crate::extract::AgentManagementWriteAccess, + Json(req): Json, +) -> Result, HttpError> { + use base64::Engine as _; + + let conf = conf_handle.get_conf(); + + let handle = agent_tunnel_handle + .as_ref() + .ok_or_else(|| HttpError::not_found().msg("agent tunnel not configured"))?; + + let lifetime_secs = req.lifetime.unwrap_or(3600); + + // Generate a one-time enrollment token. + let enrollment_token = Uuid::new_v4().to_string(); + handle + .enrollment_token_store() + .insert(enrollment_token.clone(), req.name.clone(), Some(lifetime_secs)); + + // Determine QUIC host: explicit override > extract from api_base_url > gateway hostname config. + // The gateway hostname config is often a container ID in Docker, so we prefer + // extracting the host from the api_base_url which the caller already knows is reachable. + let quic_host = match req.quic_host.as_deref().filter(|h| !h.is_empty()) { + Some(host) => host.to_owned(), + None => url::Url::parse(&req.api_base_url) + .ok() + .and_then(|u| u.host_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| conf.hostname.clone()), + }; + let quic_endpoint = format!("{quic_host}:{}", conf.agent_tunnel.listen_port); + + // Build the enrollment payload. + let payload = serde_json::json!({ + "version": 1, + "api_base_url": req.api_base_url, + "quic_endpoint": quic_endpoint, + "enrollment_token": enrollment_token, + "name": req.name, + }); + + let payload_json = serde_json::to_string(&payload) + .map_err(HttpError::internal().with_msg("serialize enrollment payload").err())?; + + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + let enrollment_string = format!("dgw-enroll:v1:{encoded}"); + let enrollment_command = format!("devolutions-agent up --enrollment-string \"{enrollment_string}\""); + + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let expires_at_unix = now_secs + lifetime_secs; + + info!( + agent_name = ?req.name, + lifetime_secs, + "Generated agent enrollment string" + ); + + Ok(Json(AgentEnrollmentStringResponse { + enrollment_string, + enrollment_command, + quic_endpoint, + expires_at_unix, + })) +} + mod login_rate_limit { use std::collections::HashMap; use std::net::IpAddr; diff --git a/devolutions-gateway/src/extract.rs b/devolutions-gateway/src/extract.rs index ada08ce9a..1205bc706 100644 --- a/devolutions-gateway/src/extract.rs +++ b/devolutions-gateway/src/extract.rs @@ -388,7 +388,8 @@ where /// Grants read access to agent management endpoints. /// -/// Accepts a scope token with `DiagnosticsRead`, `ConfigWrite`, or `Wildcard` scope. +/// Accepts either a scope token with `DiagnosticsRead` (or `Wildcard`) scope, +/// or a valid `WebApp` token. #[derive(Clone, Copy)] pub struct AgentManagementReadAccess; @@ -419,6 +420,8 @@ where /// Grants write access to agent management endpoints (e.g. enrollment, delete). /// /// Accepts scope tokens with `ConfigWrite` (or `Wildcard`) scope only. +/// The standalone webapp exchanges its WebApp token for a scope token via +/// `/jet/webapp/agent-management-token` first — no direct WebApp token bypass. #[derive(Clone, Copy)] pub struct AgentManagementWriteAccess; diff --git a/webapp/apps/gateway-ui/proxy.conf.json b/webapp/apps/gateway-ui/proxy.conf.json index aa00f838c..a3d716965 100644 --- a/webapp/apps/gateway-ui/proxy.conf.json +++ b/webapp/apps/gateway-ui/proxy.conf.json @@ -1,33 +1,41 @@ { "/jet/webapp/app-token": { - "target": "http://localhost:7171", + "target": "http://127.0.0.1:7272", "secure": false }, "/jet/webapp/session-token": { - "target": "http://localhost:7171", + "target": "http://127.0.0.1:7272", + "secure": false + }, + "/jet/webapp/agent-management-token": { + "target": "http://127.0.0.1:7272", "secure": false }, "/jet/rdp": { - "target": "http://localhost:7171", + "target": "http://127.0.0.1:7272", "secure": false, "ws": true }, "/jet/fwd/tcp": { - "target": "http://localhost:7171", + "target": "http://127.0.0.1:7272", "secure": false, "ws": true }, "/jet/KdcProxy": { - "target": "http://localhost:7171", + "target": "http://127.0.0.1:7272", "secure": false }, "/jet/health": { - "target": "http://localhost:7171", + "target": "http://127.0.0.1:7272", "secure": false }, "/jet/net/scan": { - "target": "http://localhost:7171", + "target": "http://127.0.0.1:7272", "secure": false, "ws": true + }, + "/jet/agent-tunnel": { + "target": "http://127.0.0.1:7272", + "secure": false } -} \ No newline at end of file +} diff --git a/webapp/apps/gateway-ui/src/client/app/app-auth.interceptor.ts b/webapp/apps/gateway-ui/src/client/app/app-auth.interceptor.ts index c4fc0701c..9e027b97f 100644 --- a/webapp/apps/gateway-ui/src/client/app/app-auth.interceptor.ts +++ b/webapp/apps/gateway-ui/src/client/app/app-auth.interceptor.ts @@ -18,6 +18,9 @@ export class AuthInterceptor implements HttpInterceptor { // If the request is for the app token, we don't need to add the Authorization header const goToNext = []; goToNext.push(req.url.endsWith(this.appTokenUrl)); + // Requests that already carry their own Authorization header (e.g. agent tunnel + // endpoints using scope tokens) should not be overwritten by the app token. + goToNext.push(req.headers.has('Authorization')); // If the requesting third party host, we don't need to add the Authorization header try { diff --git a/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.html b/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.html index b68e159a1..52be3a085 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.html @@ -34,7 +34,8 @@ + [iconOnly]="isMenuSlim" + (click)="menuKVP.value.executeAction()"> diff --git a/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.ts index 7425275e3..0fd01f518 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.ts @@ -92,6 +92,18 @@ export class AppMenuComponent extends BaseComponent implements OnInit { ); this.mainMenus.set('Sessions', sessionsMenuItem); + + const agentsMenuItem: RouterMenuItem = this.createMenuItem( + 'Agents', + '', + (): void => { + this.navigationService.navigateToPath('/session/agents').then(noop); + }, + (url: string) => url.startsWith('/session/agents'), + true, + ); + + this.mainMenus.set('Agents', agentsMenuItem); } private createMenuItem( diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.html new file mode 100644 index 000000000..fff1f623c --- /dev/null +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.html @@ -0,0 +1,158 @@ +
+ + + + +
+
+ Enroll New Agent + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+ + {{ enrollmentError }} +
+ +
+
+
+ Enrollment String + (paste into MSI installer or agent CLI) + +
+ {{ enrollmentResult.enrollment_string }} +
+ +
+ + + {{ enrollmentResult.quic_endpoint }} + + + + Expires {{ humanizeExpiry(enrollmentResult.expires_at_unix) }} + +
+
+
+ + +
+
+ + Loading agents... +
+ +
+ + + + + + + + No agents connected + Deploy an agent in your private network and enroll it to get started. +
+ +
+
+
+
+ {{ agent.name }} + {{ agent.agent_id }} +
+
+ +
+
+ Subnets +
+ {{ s }} +
+
+
+ Domains +
+ + {{ d.domain }} + auto + +
+
+
+ No routes configured +
+
+ +
+ + {{ agent.is_online ? 'Online' : 'Offline' }} + + {{ humanizeLastSeen(agent.last_seen_ms) }} +
+ +
+ + + Delete? + + + +
+
+
+
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.scss b/webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.scss new file mode 100644 index 000000000..317e8313e --- /dev/null +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.scss @@ -0,0 +1,527 @@ +// Agent Page — aligned with Devolutions Gateway design language +// Uses app CSS variables for theme compatibility (light/dark) + +// ─── Buttons (self-contained, no PrimeIcons dependency) ────── + +.btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.85rem; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + border: none; + transition: background 0.15s, opacity 0.15s; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.btn-primary { + background: rgb(var(--base-accent-rgb)); + color: #fff; + &:hover:not(:disabled) { opacity: 0.88; } +} + +.btn-outlined { + background: transparent; + border: 1px solid rgba(var(--base-border-rgb), 0.15); + color: rgba(var(--base-color-rgb), 0.7); + &:hover { background: rgba(var(--base-color-rgb), 0.04); } +} + +.btn-text { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.2rem 0.5rem; + border: none; + background: none; + font-size: 0.8rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + color: rgba(var(--base-color-rgb), 0.5); + border-radius: 4px; + &:hover { background: rgba(var(--base-color-rgb), 0.05); } +} + +.btn-danger-text { + color: #d32f2f; +} + +.btn-icon-only { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + background: none; + cursor: pointer; + border-radius: 6px; + color: rgba(var(--base-color-rgb), 0.45); + transition: background 0.15s; + + &:hover { background: rgba(var(--base-color-rgb), 0.06); } + + svg { width: 16px; height: 16px; } + + &.btn-danger { + color: rgba(var(--base-color-rgb), 0.35); + &:hover { background: rgba(211, 47, 47, 0.08); color: #d32f2f; } + } +} + +.btn-icon { + width: 15px; + height: 15px; + flex-shrink: 0; +} + +.btn-icon-sm { + width: 13px; + height: 13px; + flex-shrink: 0; +} + +.spinner { + width: 24px; + height: 24px; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.agent-page { + padding: 1.5rem 2rem; + max-width: 1100px; +} + +// ─── Header ─────────────────────────────────────────────────── + +.page-header { + margin-bottom: 1.5rem; +} + +.header-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + + h2 { + margin: 0; + font-size: 1.4rem; + font-weight: 600; + color: var(--title-color); + } +} + +.header-actions { + display: flex; + gap: 0.5rem; +} + +.page-subtitle { + margin: 0.35rem 0 0; + font-size: 0.875rem; + color: rgba(var(--base-color-rgb), 0.5); +} + +// ─── Enrollment Panel ───────────────────────────────────────── + +.enrollment-panel { + margin-bottom: 1.5rem; + padding: 1.25rem; + border: 1px solid rgba(var(--base-border-rgb), 0.1); + border-radius: 8px; + background: rgba(var(--base-accent-rgb), 0.03); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.panel-title { + font-weight: 600; + font-size: 1rem; + color: var(--title-color); +} + +.enrollment-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem 1rem; + + .form-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + + label { + font-size: 0.8rem; + font-weight: 500; + color: rgba(var(--base-color-rgb), 0.6); + text-transform: uppercase; + letter-spacing: 0.03em; + } + + input { + width: 100%; + font-size: 0.9rem; + } + } +} + +.enrollment-actions { + margin-top: 1rem; +} + +.enrollment-error { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; + padding: 0.65rem 0.85rem; + border-radius: 6px; + font-size: 0.85rem; + background: rgba(211, 47, 47, 0.06); + color: #c62828; + border: 1px solid rgba(211, 47, 47, 0.12); + + .dark-theme & { + background: rgba(239, 83, 80, 0.1); + color: #ef5350; + border-color: rgba(239, 83, 80, 0.15); + } +} + +.error-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.enrollment-result { + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1px solid rgba(var(--base-border-rgb), 0.08); +} + +.result-block { + margin-bottom: 0.75rem; +} + +.result-label { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.35rem; + + span { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: rgba(var(--base-color-rgb), 0.5); + } + + .result-hint { + font-weight: 400; + text-transform: none; + letter-spacing: normal; + font-size: 0.75rem; + color: rgba(var(--base-color-rgb), 0.35); + } + + .btn-text { + margin-left: auto; + } +} + +.code-block { + display: block; + padding: 0.65rem 0.85rem; + background: rgba(var(--base-color-rgb), 0.04); + border: 1px solid rgba(var(--base-border-rgb), 0.08); + border-radius: 6px; + font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace; + font-size: 0.8rem; + line-height: 1.5; + word-break: break-all; + white-space: pre-wrap; + color: rgba(var(--base-color-rgb), 0.85); +} + +.result-meta { + display: flex; + gap: 1.5rem; + margin-top: 0.5rem; +} + +.meta-item { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.8rem; + color: rgba(var(--base-color-rgb), 0.45); +} + +.meta-icon { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +// ─── Agent List ─────────────────────────────────────────────── + +.agent-list { + display: flex; + flex-direction: column; +} + +.loading-state { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 2rem; + justify-content: center; + color: rgba(var(--base-color-rgb), 0.45); + font-size: 0.9rem; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + padding: 3rem 2rem; + text-align: center; + border: 1px dashed rgba(var(--base-border-rgb), 0.12); + border-radius: 8px; +} + +.empty-icon { + width: 48px; + height: 48px; + color: rgba(var(--base-color-rgb), 0.15); + margin-bottom: 0.75rem; +} + +.empty-title { + font-size: 1rem; + font-weight: 600; + color: rgba(var(--base-color-rgb), 0.5); + margin-bottom: 0.25rem; +} + +.empty-desc { + font-size: 0.85rem; + color: rgba(var(--base-color-rgb), 0.35); + max-width: 360px; +} + +// ─── Agent Row ──────────────────────────────────────────────── + +.agent-row { + display: grid; + grid-template-columns: minmax(200px, 1fr) 2fr auto auto; + align-items: center; + gap: 1.5rem; + padding: 0.85rem 1rem; + border-bottom: 1px solid rgba(var(--base-border-rgb), 0.06); + transition: background 0.15s ease; + + &:hover { + background: rgba(var(--base-color-rgb), 0.02); + } + + &:last-child { + border-bottom: none; + } +} + +.agent-identity { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 0; +} + +.agent-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &.online { + background: #4caf50; + box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15); + } + + &.offline { + background: rgba(var(--base-color-rgb), 0.2); + } +} + +.agent-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.agent-name { + font-weight: 600; + font-size: 0.925rem; + color: rgba(var(--base-color-rgb), 0.85); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.agent-id { + font-family: 'Cascadia Code', 'Fira Code', monospace; + font-size: 0.7rem; + color: rgba(var(--base-color-rgb), 0.3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// ─── Routes ─────────────────────────────────────────────────── + +.agent-routes { + display: flex; + gap: 1rem; + flex-wrap: wrap; + min-width: 0; +} + +.route-group { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; +} + +.route-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(var(--base-color-rgb), 0.3); + white-space: nowrap; +} + +.no-routes .route-label { + font-style: italic; + font-weight: 400; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-family: 'Cascadia Code', 'Fira Code', monospace; + font-size: 0.72rem; + white-space: nowrap; +} + +.subnet-tag { + background: rgba(var(--base-accent-rgb), 0.08); + color: rgb(var(--base-accent-rgb)); +} + +.domain-tag { + background: rgba(106, 185, 52, 0.08); + color: #4a8a24; + + .dark-theme & { + background: rgba(106, 185, 52, 0.12); + color: #7bc342; + } +} + +.auto-indicator { + font-size: 0.6rem; + font-family: 'Open Sans', sans-serif; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.05rem 0.3rem; + border-radius: 3px; + background: rgba(var(--base-color-rgb), 0.06); + color: rgba(var(--base-color-rgb), 0.4); +} + +// ─── Meta & Actions ─────────────────────────────────────────── + +.agent-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.15rem; + white-space: nowrap; +} + +.meta-badge { + font-size: 0.72rem; + font-weight: 600; + padding: 0.15rem 0.55rem; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.03em; + + &.online { + background: rgba(76, 175, 80, 0.1); + color: #2e7d32; + + .dark-theme & { + background: rgba(76, 175, 80, 0.15); + color: #66bb6a; + } + } + + &.offline { + background: rgba(var(--base-color-rgb), 0.06); + color: rgba(var(--base-color-rgb), 0.4); + } +} + +.last-seen { + font-size: 0.72rem; + color: rgba(var(--base-color-rgb), 0.35); +} + +.agent-actions { + display: flex; + align-items: center; + justify-content: flex-end; + min-width: 80px; +} + +.delete-confirm { + display: flex; + align-items: center; + gap: 0.15rem; + font-size: 0.8rem; + color: rgba(var(--base-color-rgb), 0.6); +} diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.ts new file mode 100644 index 000000000..58c4c274b --- /dev/null +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/agent-enrollment/agent-enrollment.component.ts @@ -0,0 +1,138 @@ +import { Component, OnInit } from '@angular/core'; +import { AgentEnrollmentStringResponse, AgentInfo } from '@shared/interfaces/agent.interfaces'; +import { ApiService } from '@shared/services/api.service'; +import { BaseComponent } from '@shared/bases/base.component'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + standalone: false, + selector: 'app-agent-enrollment', + templateUrl: './agent-enrollment.component.html', + styleUrls: ['./agent-enrollment.component.scss'], +}) +export class AgentEnrollmentComponent extends BaseComponent implements OnInit { + agents: AgentInfo[] = []; + loading = false; + + // Enrollment form + apiBaseUrl: string = window.location.origin; + quicHost: string = window.location.hostname; + agentName = ''; + tokenLifetime = 3600; + + // Generated enrollment data + enrollmentResult: AgentEnrollmentStringResponse | null = null; + generating = false; + + // UI state + showEnrollment = false; + enrollmentError: string | null = null; + + // Delete confirmation + agentToDelete: AgentInfo | null = null; + + constructor(private apiService: ApiService) { + super(); + } + + ngOnInit(): void { + this.loadAgents(); + } + + loadAgents(): void { + this.loading = true; + this.apiService + .listAgents() + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: (agents) => { + this.agents = agents; + this.loading = false; + }, + error: (err) => { + console.error('Failed to load agents', err); + this.loading = false; + }, + }); + } + + generateEnrollmentString(): void { + this.generating = true; + this.enrollmentResult = null; + this.enrollmentError = null; + + this.apiService + .generateAgentEnrollmentString({ + api_base_url: this.apiBaseUrl, + quic_host: this.quicHost || undefined, + name: this.agentName || undefined, + lifetime: this.tokenLifetime, + }) + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: (result) => { + this.enrollmentResult = result; + this.generating = false; + }, + error: (err) => { + console.error('Failed to generate enrollment string', err); + this.enrollmentError = err?.status === 404 + ? 'Agent tunnel endpoint not available. Ensure the gateway is running with agent tunnel enabled.' + : `Failed to generate enrollment command: ${err?.statusText || err?.message || 'Unknown error'}`; + this.generating = false; + }, + }); + } + + confirmDelete(agent: AgentInfo): void { + this.agentToDelete = agent; + } + + cancelDelete(): void { + this.agentToDelete = null; + } + + deleteAgent(): void { + if (!this.agentToDelete) return; + const agentId = this.agentToDelete.agent_id; + this.agentToDelete = null; + + this.apiService + .deleteAgent(agentId) + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: () => { + this.agents = this.agents.filter((a) => a.agent_id !== agentId); + }, + error: (err) => { + console.error('Failed to delete agent', err); + }, + }); + } + + copyToClipboard(text: string): void { + navigator.clipboard.writeText(text).catch((err) => { + console.error('Failed to copy to clipboard', err); + }); + } + + humanizeExpiry(unixTimestamp: number): string { + if (!unixTimestamp) return 'unknown'; + const diff = unixTimestamp * 1000 - Date.now(); + if (diff <= 0) return 'expired'; + if (diff < 3_600_000) return `in ${Math.floor(diff / 60_000)}m`; + return `in ${Math.floor(diff / 3_600_000)}h`; + } + + humanizeLastSeen(lastSeenMs: number): string { + if (!lastSeenMs) return 'Never'; + const now = Date.now(); + const diffMs = now - lastSeenMs; + + if (diffMs < 0) return 'Just now'; + if (diffMs < 60_000) return `${Math.floor(diffMs / 1000)}s ago`; + if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`; + if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`; + return `${Math.floor(diffMs / 86_400_000)}d ago`; + } +} diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.ts index a2d4ebaa0..2d84c9fee 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.ts @@ -389,6 +389,7 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI ardQualityMode, wheelSpeedFactor, sessionId, + agentId: (formData as { agentId?: string }).agentId || undefined, }; return of(connectionParameters); } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ard/ard-form.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ard/ard-form.component.html index c2e28b35f..911cd80e4 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ard/ard-form.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ard/ard-form.component.html @@ -35,4 +35,9 @@ +
+ +
+ diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/rdp/rdp-form.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/rdp/rdp-form.component.html index 8c6bbe9a3..55960afc3 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/rdp/rdp-form.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/rdp/rdp-form.component.html @@ -43,4 +43,9 @@ +
+ +
+ diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ssh/ssh-form.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ssh/ssh-form.component.html index fc1caea9f..e59a9f95b 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ssh/ssh-form.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ssh/ssh-form.component.html @@ -51,4 +51,9 @@ [disabled]="formInputVisibility.showPrivateKeyInput" > + +
+ +
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/vnc/vnc-form.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/vnc/vnc-form.component.html index bc4c15719..5fdec075c 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/vnc/vnc-form.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/vnc/vnc-form.component.html @@ -93,4 +93,9 @@ > + +
+ +
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.html new file mode 100644 index 000000000..d50d257c0 --- /dev/null +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.html @@ -0,0 +1,19 @@ +
+ +
+ + + {{ getAgentLabel(agent) }} + + +
+
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.scss b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.ts new file mode 100644 index 000000000..9ba50fa9a --- /dev/null +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/agent-selector-control/agent-selector-control.component.ts @@ -0,0 +1,57 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { AgentInfo } from '@shared/interfaces/agent.interfaces'; +import { ApiService } from '@shared/services/api.service'; +import { BaseComponent } from '@shared/bases/base.component'; +import { WebFormService } from '@shared/services/web-form.service'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + standalone: false, + selector: 'web-client-agent-selector-control', + templateUrl: './agent-selector-control.component.html', + styleUrls: ['./agent-selector-control.component.scss'], +}) +export class AgentSelectorControlComponent extends BaseComponent implements OnInit { + @Input() parentForm: FormGroup; + @Input() inputFormData; + + agents: AgentInfo[] = []; + hasAgents = false; + + constructor( + private apiService: ApiService, + private formService: WebFormService, + ) { + super(); + } + + ngOnInit(): void { + this.formService.addControlToForm({ + formGroup: this.parentForm, + controlName: 'agentId', + inputFormData: this.inputFormData, + isRequired: false, + defaultValue: null, + }); + + this.apiService + .listAgents() + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: (agents) => { + this.agents = agents.filter((a) => a.is_online); + this.hasAgents = this.agents.length > 0; + }, + error: () => { + this.agents = []; + this.hasAgents = false; + }, + }); + } + + getAgentLabel(agent: AgentInfo): string { + const subnets = agent.subnets.length > 0 ? ` (${agent.subnets.join(', ')})` : ''; + return `${agent.name}${subnets}`; + } +} diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/web-client-form.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/web-client-form.component.html index 5b8c19ef1..cb9544f9d 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/web-client-form.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/web-client-form.component.html @@ -82,7 +82,11 @@ [inputFormData]="inputFormData" > - + +
+ +
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts index d670e824e..ba4400086 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts @@ -487,6 +487,7 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI enableDisplayControl, preConnectionBlob, kdcUrl: this.utils.string.ensurePort(kdcUrl, ':88'), + agentId: (formData as { agentId?: string }).agentId || undefined, }; return of(connectionParameters); } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts index fd284adc9..160a3cdba 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts @@ -197,6 +197,7 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web sessionId: sessionId, privateKey: privateKey, privateKeyPassphrase: privateKeyPassphrase, + agentId: (formData as { agentId?: string }).agentId || undefined, }; return of(connectionParameters); } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts index 363a264fb..219176ae2 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts @@ -191,6 +191,7 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements port: extractedData.port, gatewayAddress: gatewayAddress, sessionId: sessionId, + agentId: (formData as { agentId?: string }).agentId || undefined, }; return of(connectionParameters); } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts index 27fb26d6d..0f3ebd5df 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts @@ -493,6 +493,7 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI enableExtendedClipboard: enableExtendedClipboard ?? false, ultraVirtualDisplay, wheelSpeedFactor, + agentId: (formData as { agentId?: string }).agentId || undefined, }; return of(connectionParameters); } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts index 3ab8f41f6..47449b637 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts @@ -2,11 +2,13 @@ import { NgOptimizedImage } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { RouterModule, Routes } from '@angular/router'; +import { AgentEnrollmentComponent } from '@gateway/modules/web-client/agent-enrollment/agent-enrollment.component'; import { WebClientArdComponent } from '@gateway/modules/web-client/ard/web-client-ard.component'; import { ArdFormComponent } from '@gateway/modules/web-client/form/form-components/ard/ard-form.component'; import { RdpFormComponent } from '@gateway/modules/web-client/form/form-components/rdp/rdp-form.component'; import { SshFormComponent } from '@gateway/modules/web-client/form/form-components/ssh/ssh-form.component'; import { VncFormComponent } from '@gateway/modules/web-client/form/form-components/vnc/vnc-form.component'; +import { AgentSelectorControlComponent } from './form/form-controls/agent-selector-control/agent-selector-control.component'; import { EnableCursorControlComponent } from '@gateway/modules/web-client/form/form-controls/enable-cursor-control/enable-cursor-control.component'; import { EnableDisplayConfigurationControlComponent } from '@gateway/modules/web-client/form/form-controls/enable-display-configuration-control/enable-display-configuration-control.component'; import { KdcUrlControlComponent } from '@gateway/modules/web-client/form/form-controls/kdc-url-control/kdc-url-control.component'; @@ -53,6 +55,10 @@ const routes: Routes = [ wasmInit: WasmInitResolver, }, }, + { + path: 'agents', + component: AgentEnrollmentComponent, + }, ]; @NgModule({ @@ -104,6 +110,8 @@ const routes: Routes = [ SessionToolbarComponent, FileControlComponent, NetScanComponent, + AgentEnrollmentComponent, + AgentSelectorControlComponent, ], exports: [DynamicTabComponent, WebClientFormComponent, NetScanComponent], providers: [], diff --git a/webapp/apps/gateway-ui/src/client/app/shared/interfaces/agent.interfaces.ts b/webapp/apps/gateway-ui/src/client/app/shared/interfaces/agent.interfaces.ts new file mode 100644 index 000000000..356c085ef --- /dev/null +++ b/webapp/apps/gateway-ui/src/client/app/shared/interfaces/agent.interfaces.ts @@ -0,0 +1,29 @@ +export interface DomainInfo { + domain: string; + auto_detected: boolean; +} + +export interface AgentInfo { + agent_id: string; + name: string; + cert_fingerprint: string; + is_online: boolean; + last_seen_ms: number; + subnets: string[]; + domains: DomainInfo[]; + route_epoch?: number; +} + +export interface AgentEnrollmentStringRequest { + api_base_url: string; + quic_host?: string; + name?: string; + lifetime?: number; +} + +export interface AgentEnrollmentStringResponse { + enrollment_string: string; + enrollment_command: string; + quic_endpoint: string; + expires_at_unix: number; +} diff --git a/webapp/apps/gateway-ui/src/client/app/shared/interfaces/connection-params.interfaces.ts b/webapp/apps/gateway-ui/src/client/app/shared/interfaces/connection-params.interfaces.ts index 354301157..ad6351ef1 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/interfaces/connection-params.interfaces.ts +++ b/webapp/apps/gateway-ui/src/client/app/shared/interfaces/connection-params.interfaces.ts @@ -8,6 +8,7 @@ export interface SessionTokenParameters { session_id?: string; krb_kdc?: string; krb_realm?: string; + agent_id?: string; } export interface IronRDPConnectionParameters { @@ -25,6 +26,7 @@ export interface IronRDPConnectionParameters { kdcProxyUrl?: string; preConnectionBlob?: string; sessionId?: string; + agentId?: string; } export interface IronVNCConnectionParameters { @@ -43,6 +45,7 @@ export interface IronVNCConnectionParameters { ultraVirtualDisplay: boolean; wheelSpeedFactor: number; sessionId?: string; + agentId?: string; } export interface IronARDConnectionParameters { @@ -56,6 +59,7 @@ export interface IronARDConnectionParameters { ardQualityMode?: string; wheelSpeedFactor: number; sessionId?: string; + agentId?: string; } export interface TelnetConnectionParameters { @@ -64,6 +68,7 @@ export interface TelnetConnectionParameters { gatewayAddress?: string; token?: string; sessionId?: string; + agentId?: string; } export interface SshConnectionParameters { @@ -76,4 +81,5 @@ export interface SshConnectionParameters { sessionId?: string; privateKey?: string; privateKeyPassphrase?: string; + agentId?: string; } diff --git a/webapp/apps/gateway-ui/src/client/app/shared/services/api.service.ts b/webapp/apps/gateway-ui/src/client/app/shared/services/api.service.ts index 1dc7aea95..99f206c92 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/services/api.service.ts +++ b/webapp/apps/gateway-ui/src/client/app/shared/services/api.service.ts @@ -1,7 +1,12 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable, of, throwError } from 'rxjs'; -import { catchError, map, tap } from 'rxjs/operators'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; +import { + AgentEnrollmentStringRequest, + AgentEnrollmentStringResponse, + AgentInfo, +} from '../interfaces/agent.interfaces'; import { SessionTokenParameters } from '../interfaces/connection-params.interfaces'; interface VersionInfo { @@ -25,6 +30,10 @@ export class ApiService { private sessionTokenApiURL = '/jet/webapp/session-token'; private healthApiURL = '/jet/health'; private devolutionProductApiURL = 'https://devolutions.net/products.htm'; + private agentManagementTokenApiUrl = '/jet/webapp/agent-management-token'; + private agentsApiURL = '/jet/agent-tunnel/agents'; + private agentEnrollmentStringApiUrl = '/jet/agent-tunnel/enrollment-string'; + constructor(private http: HttpClient) {} generateAppToken(username?: string, password?: string) { @@ -107,4 +116,47 @@ export class ApiService { }), ); } + + /** Exchange the webapp app-token for a fresh agent management scope token. */ + private getAgentManagementToken(): Observable { + return this.http.post(this.agentManagementTokenApiUrl, {}, { responseType: 'text' }); + } + + private agentHeaders(): Observable { + return this.getAgentManagementToken().pipe( + map( + (token) => + new HttpHeaders({ + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }), + ), + ); + } + + listAgents(): Observable { + return this.agentHeaders().pipe( + switchMap((headers) => this.http.get(this.agentsApiURL, { headers })), + ); + } + + getAgent(agentId: string): Observable { + return this.agentHeaders().pipe( + switchMap((headers) => this.http.get(`${this.agentsApiURL}/${agentId}`, { headers })), + ); + } + + deleteAgent(agentId: string): Observable { + return this.agentHeaders().pipe( + switchMap((headers) => this.http.delete(`${this.agentsApiURL}/${agentId}`, { headers })), + ); + } + + generateAgentEnrollmentString( + request: AgentEnrollmentStringRequest, + ): Observable { + return this.agentHeaders().pipe( + switchMap((headers) => this.http.post(this.agentEnrollmentStringApiUrl, request, { headers })), + ); + } } diff --git a/webapp/apps/gateway-ui/src/client/app/shared/services/web-client.service.ts b/webapp/apps/gateway-ui/src/client/app/shared/services/web-client.service.ts index fe00ce865..a98ecc332 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/services/web-client.service.ts +++ b/webapp/apps/gateway-ui/src/client/app/shared/services/web-client.service.ts @@ -118,6 +118,7 @@ export class WebClientService extends BaseComponent { destination: `tcp://${connectionParameters.host}:${connectionParameters.port ?? this.getDefaultPort(protocol)}`, lifetime: 60, session_id: connectionParameters.sessionId || uuidv4(), + agent_id: (connectionParameters as { agentId?: string }).agentId || undefined, }; return this.fetchToken(data).pipe(