diff --git a/operator/Cargo.toml b/operator/Cargo.toml index fea5b37ff..dd0f04892 100644 --- a/operator/Cargo.toml +++ b/operator/Cargo.toml @@ -36,3 +36,4 @@ toml = "0.8" anyhow = "1.0" dirs = "6" rpassword = "7" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } diff --git a/operator/README.md b/operator/README.md index 0e4fbb0d9..c98d282dc 100644 --- a/operator/README.md +++ b/operator/README.md @@ -196,7 +196,7 @@ metadata: name: my-bot namespace: prod spec: - image: public.ecr.aws/oablab/kiro:stable + image: ghcr.io/openabdev/openab:stable-kiro resources: cpu: "256" memory: "512" @@ -224,7 +224,7 @@ linked here. ```yaml spec: - image: public.ecr.aws/oablab/kiro:beta + image: ghcr.io/openabdev/openab:beta-kiro resources: { cpu: "256", memory: "512" } configFrom: s3://.../config.toml runtime: @@ -242,6 +242,42 @@ spec: - /webhook/line ``` +#### Minimal working manifest: Telegram on AWS + +The complete set of fields actually required to deploy a Telegram bot with +ingress — everything else in the schema has a default. Save as a file and +`oabctl apply -f `: + +```yaml +apiVersion: oab.dev/v2 +kind: OABService +metadata: + name: my-telegram-bot + namespace: prod +spec: + image: ghcr.io/openabdev/openab:stable-kiro + resources: { cpu: "256", memory: "512" } + configFrom: s3:///config.toml + secrets: + TELEGRAM_BOT_TOKEN: "aws-sm://oab/telegram/my-telegram-bot#TELEGRAM_BOT_TOKEN" + runtime: + type: ecs + networking: + subnets: [subnet-aaa, subnet-bbb] + securityGroups: [sg-xxx] + ingress: + paths: + - /webhook/telegram +``` + +`spec.secrets.TELEGRAM_BOT_TOKEN` isn't required by the schema (`spec.secrets` +defaults to empty), but without it the bot has no way to authenticate to +Telegram and `apply` has nothing to call `setWebhook` with — include it for +Telegram to actually work. `configFrom` must point at a `config.toml` with a +matching `[telegram]` block (`webhook_path = "/webhook/telegram"`, +`bot_token = "${TELEGRAM_BOT_TOKEN}"`) — see +[docs/telegram.md](../docs/telegram.md) for the full config reference. + On `apply` this reconciles (idempotently, reused by name): 1. **Cloud Map** private DNS namespace (`-`, shared per-VPC) + a per-service **SRV** record (carries the container port; a plain A record does not work as a VPC-Link integration target) @@ -251,8 +287,7 @@ On `apply` this reconciles (idempotently, reused by name): 5. One **route** per path + a `prod` auto-deploy **stage** 6. A self-referencing **security-group** inbound rule on `containerPort` -`apply` then prints the stable webhook URL(s) to register with BotFather / the -LINE console: +`apply` then prints the stable webhook URL(s): ``` 🔗 Webhook URL(s) for my-bot: @@ -260,15 +295,30 @@ LINE console: https://{api-id}.execute-api.{region}.amazonaws.com/prod/webhook/line ``` +For Telegram, if `/webhook/telegram` is one of `spec.ingress.paths` and +`spec.secrets` has a `TELEGRAM_BOT_TOKEN` entry, `apply` also registers the +webhook URL with Telegram directly (calling `setWebhook` on your behalf) — no +manual `curl` step needed. If `spec.secrets` also has a +`TELEGRAM_SECRET_TOKEN` entry, it's passed through so Telegram signs every +webhook request with it (see the security note below). This is best-effort +and never fails `apply` — if it errors (bad token, network blip), `apply` +still succeeds and prints a warning; register the webhook yourself with the +printed URL in that case. LINE has no `setWebhook`-equivalent API, so its URL +must still be registered manually in the LINE Developers console. + > **Security note:** the API Gateway endpoint itself is public and unauthenticated > at the transport layer (no IAM auth, no API key). OpenAB's webhook handlers add > their own app-layer verification on top: Telegram validates the > `X-Telegram-Bot-Api-Secret-Token` header (`TELEGRAM_SECRET_TOKEN`) and the > request's source IP against Telegram's published webhook subnets; LINE > verifies an HMAC-SHA256 signature using `LINE_CHANNEL_SECRET`. Set -> `TELEGRAM_SECRET_TOKEN` when registering the webhook with BotFather to enable -> that check. +> `TELEGRAM_SECRET_TOKEN` in `spec.secrets` to enable that check — `apply` +> passes it to Telegram automatically as described above. `TELEGRAM_SECRET_TOKEN` +> is Telegram's own `secret_token` hardening mechanism for `setWebhook`, not an +> openab-specific convention — see the [official Telegram Bot API +> docs](https://core.telegram.org/bots/api#setwebhook) for details. > + > **Stage prefix stripped before it reaches the backend:** for private > (VPC_LINK) integrations, API Gateway forwards the *stage-prefixed* request > path to the backend by default (e.g. `/prod/webhook/telegram`, not @@ -336,7 +386,7 @@ metadata: namespace: prod spec: template: - image: public.ecr.aws/oablab/kiro:stable + image: ghcr.io/openabdev/openab:stable-kiro resources: { cpu: "256", memory: "512" } runtime: type: ecs @@ -395,6 +445,47 @@ Attached to `oab-task-role` — the identity the *running container* assumes. | `oab-s3-artifacts` | s3:GetObject, s3:PutObject | `{bucket}/artifacts/*` | | `oab-secrets` | secretsmanager:GetSecretValue | `arn:aws:secretsmanager:*:*:secret:oab/*` | +### `spec.secrets` value format + +`spec.secrets` maps a container env var name to where ECS should fetch its +value from at task launch (via the execution role below — the container +itself never sees a raw secret reference, only the resolved value). Two +formats are accepted **per value** — either works for any key, and both can +be mixed freely across different keys in the same manifest. The example +below uses the two Telegram-specific keys from "Auto-register the Telegram +webhook" above, annotated with what each is for: + +```yaml +spec: + secrets: + # TELEGRAM_BOT_TOKEN — the bot's API token from BotFather. Required for + # openab to authenticate as the bot, and for `apply` to auto-register + # the webhook via setWebhook (see above). + # + # Format 1 — ECS-native valueFrom: a full Secrets Manager ARN. Add a + # `:::` suffix to extract one field of a JSON secret; omit it + # for a plain-string secret. + TELEGRAM_BOT_TOKEN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:oab/telegram/mybot-AC80TP:TELEGRAM_BOT_TOKEN::" + # TELEGRAM_BOT_TOKEN: "aws-sm://oab/telegram/mybot#TELEGRAM_BOT_TOKEN" # equivalent, Format 2 below + + # TELEGRAM_SECRET_TOKEN — optional. Telegram's own setWebhook + # secret_token request-signing hardening (see the security note above), + # not an openab-specific convention. `apply` passes it to Telegram + # automatically if present. + # + # Format 2 — aws-sm://# shorthand: the same + # convention openab itself uses for in-app secret refs in config.toml + # (see docs/secrets-management.md). oabctl resolves this to the + # ECS-native form automatically; can be a bare secret name + # (resolved to its ARN via DescribeSecret) or a full ARN directly. + TELEGRAM_SECRET_TOKEN: "aws-sm://oab/telegram/mybot#TELEGRAM_SECRET_TOKEN" +``` + +Either format works for **any** key — `TELEGRAM_BOT_TOKEN` above could just +as well be written `"aws-sm://oab/telegram/mybot#TELEGRAM_BOT_TOKEN"`, and +`TELEGRAM_SECRET_TOKEN` could just as well use the full ARN form. The choice +is per-value, not tied to which secret it is. + ### IAM Execution Role Permissions Attached to `oab-task-execution` — the identity **ECS itself** assumes to pull diff --git a/operator/src/apply.rs b/operator/src/apply.rs index 45566a7b9..5b2f6409c 100644 --- a/operator/src/apply.rs +++ b/operator/src/apply.rs @@ -255,15 +255,18 @@ async fn apply_ecs( env_vars.push(KeyValuePair::builder().name("BOOTSTRAP_FROM").value(bootstrap).build()); } - // 3. Build secrets from map - let secrets: Vec = m - .spec - .secrets - .iter() - .map(|(name, ssm_path)| { - Secret::builder().name(name).value_from(ssm_path).build().unwrap() - }) - .collect(); + // 3. Build secrets from map. Values can be either the ECS-native + // `valueFrom` format directly (a Secrets Manager ARN, optionally with + // a `:::` suffix), or the same `aws-sm://#` + // shorthand openab itself uses for in-app secret refs — resolved here + // into the ECS-native form ECS actually requires, since ECS has no + // knowledge of that scheme. + let sm = aws_sdk_secretsmanager::Client::new(config); + let mut secrets: Vec = Vec::with_capacity(m.spec.secrets.len()); + for (name, value) in &m.spec.secrets { + let value_from = crate::secrets::resolve_value_from(&sm, value).await?; + secrets.push(Secret::builder().name(name).value_from(value_from).build().unwrap()); + } // 4. Register task definition. Resolve bootstrap state once up front — it // supplies both the CloudWatch log group (for logConfiguration below) and @@ -555,6 +558,21 @@ async fn apply_ecs( for u in &urls { println!(" {u}"); } + + // Best-effort: register the Telegram webhook so the bot starts + // receiving updates without a manual `curl setWebhook` step. Only + // fires when `/webhook/telegram` is one of the ingress paths and + // spec.secrets has a TELEGRAM_BOT_TOKEN entry; a no-op otherwise. + // Never fails `apply` — the AWS provisioning above already + // succeeded, and this is a convenience on top of it. + let path_urls: Vec<(String, String)> = + ingress.paths.iter().cloned().zip(urls.iter().cloned()).collect(); + match crate::ingress::register_telegram_webhook(config, &m.spec.secrets, &path_urls).await + { + Ok(Some(desc)) => eprintln!(" ✓ Telegram webhook registered: {desc}"), + Ok(None) => {} + Err(e) => eprintln!(" ⚠ Telegram webhook registration failed (apply still succeeded): {e}"), + } } if wait { diff --git a/operator/src/ingress.rs b/operator/src/ingress.rs index 3c33d7db9..6545e24e1 100644 --- a/operator/src/ingress.rs +++ b/operator/src/ingress.rs @@ -139,6 +139,90 @@ pub async fn ensure_gateway( Ok(webhook_urls(&api_endpoint, &ingress.paths)) } +/// Find the `/webhook/telegram` URL among the resolved webhook URLs, and +/// confirm a `TELEGRAM_BOT_TOKEN` secret is configured. Returns `None` if +/// either is missing, meaning [`register_telegram_webhook`] should no-op. +fn find_telegram_webhook( + secrets: &std::collections::HashMap, + webhook_urls: &[(String, String)], +) -> Option<(String, String)> { + let url = webhook_urls + .iter() + .find(|(path, _)| path == "/webhook/telegram") + .map(|(_, url)| url.clone())?; + let token_ref = secrets.get("TELEGRAM_BOT_TOKEN")?.clone(); + Some((url, token_ref)) +} + +/// Register the webhook URL with Telegram's Bot API (`setWebhook`), so the +/// bot starts receiving updates without a manual `curl` step. Only runs when +/// `spec.secrets` has a `TELEGRAM_BOT_TOKEN` entry and one of the ingress +/// paths is `/webhook/telegram`; a no-op otherwise. If `TELEGRAM_SECRET_TOKEN` +/// is also present, it's passed through so Telegram includes it on every +/// webhook request (openab's Telegram adapter validates it). +/// +/// Best-effort: errors are returned to the caller to print as a warning, but +/// are never fatal to `apply` — the AWS-side provisioning already succeeded +/// by this point, and a failed Telegram API call (e.g. bad token, network +/// blip) shouldn't roll any of that back or fail the whole command. +pub async fn register_telegram_webhook( + config: &aws_config::SdkConfig, + secrets: &std::collections::HashMap, + webhook_urls: &[(String, String)], +) -> Result> { + let Some((url, token_arn)) = find_telegram_webhook(secrets, webhook_urls) else { + return Ok(None); + }; + + let sm = aws_sdk_secretsmanager::Client::new(config); + let bot_token = crate::secrets::resolve_string(&sm, &token_arn) + .await + .context("failed to resolve TELEGRAM_BOT_TOKEN")?; + + let secret_token = match secrets.get("TELEGRAM_SECRET_TOKEN") { + Some(v) => Some( + crate::secrets::resolve_string(&sm, v) + .await + .context("failed to resolve TELEGRAM_SECRET_TOKEN")?, + ), + None => None, + }; + + let mut form = vec![("url".to_string(), url)]; + if let Some(st) = secret_token { + form.push(("secret_token".to_string(), st)); + } + + let client = reqwest::Client::new(); + let resp = client + .post(format!("https://api.telegram.org/bot{bot_token}/setWebhook")) + .form(&form) + .send() + .await + .context("failed to call Telegram setWebhook API")?; + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .context("failed to parse Telegram setWebhook response")?; + + if !status.is_success() || body.get("ok").and_then(|v| v.as_bool()) != Some(true) { + anyhow::bail!( + "Telegram setWebhook failed: {}", + body.get("description") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error") + ); + } + Ok(Some( + body.get("description") + .and_then(|v| v.as_str()) + .unwrap_or("webhook registered") + .to_string(), + )) +} + + /// API Gateway route key for a webhook path (POST only). fn route_key(path: &str) -> String { format!("POST {path}") @@ -945,6 +1029,35 @@ mod tests { assert!(has_stage_path_override(Some(¶ms))); } + #[test] + fn find_telegram_webhook_finds_url_and_token() { + let secrets = HashMap::from([("TELEGRAM_BOT_TOKEN".to_string(), "arn:aws:...".to_string())]); + let urls = vec![ + ("/webhook/line".to_string(), "https://x/prod/webhook/line".to_string()), + ("/webhook/telegram".to_string(), "https://x/prod/webhook/telegram".to_string()), + ]; + let (url, token) = find_telegram_webhook(&secrets, &urls).unwrap(); + assert_eq!(url, "https://x/prod/webhook/telegram"); + assert_eq!(token, "arn:aws:..."); + } + + #[test] + fn find_telegram_webhook_none_without_telegram_path() { + let secrets = HashMap::from([("TELEGRAM_BOT_TOKEN".to_string(), "arn:aws:...".to_string())]); + let urls = vec![("/webhook/line".to_string(), "https://x/prod/webhook/line".to_string())]; + assert!(find_telegram_webhook(&secrets, &urls).is_none()); + } + + #[test] + fn find_telegram_webhook_none_without_bot_token_secret() { + let secrets = HashMap::new(); + let urls = vec![( + "/webhook/telegram".to_string(), + "https://x/prod/webhook/telegram".to_string(), + )]; + assert!(find_telegram_webhook(&secrets, &urls).is_none()); + } + #[test] fn cloud_map_service_id_parses_from_arn() { assert_eq!( diff --git a/operator/src/main.rs b/operator/src/main.rs index 93ba84fcb..6c3aa3700 100644 --- a/operator/src/main.rs +++ b/operator/src/main.rs @@ -6,6 +6,7 @@ mod create; mod get; mod delete; mod ingress; +mod secrets; use clap::{Parser, Subcommand}; diff --git a/operator/src/secrets.rs b/operator/src/secrets.rs new file mode 100644 index 000000000..d284f60d9 --- /dev/null +++ b/operator/src/secrets.rs @@ -0,0 +1,125 @@ +//! Shared resolution for `spec.secrets` values. +//! +//! Values can be either a Secrets Manager reference in ECS-native +//! `valueFrom` format directly (a full ARN, optionally suffixed with +//! `:::` to extract one field of a JSON secret), or the same +//! `aws-sm://#` shorthand openab itself uses for +//! in-app secret refs (see `crates/openab-core/src/secrets.rs`) — kept +//! identical here so a manifest author can write one convention across both +//! `spec.secrets` (consumed by ECS at container launch) and `config.toml` +//! (consumed by openab itself at runtime). + +use anyhow::{Context, Result}; + +/// Parse `aws-sm://#` into `(secret_id, json_key)`. +/// Returns `None` if `value` doesn't use the `aws-sm://` scheme. +fn parse_aws_sm_uri(value: &str) -> Option> { + let rest = value.strip_prefix("aws-sm://")?; + Some(match rest.rsplit_once('#') { + Some((secret_id, json_key)) if !secret_id.is_empty() && !json_key.is_empty() => { + Ok((secret_id, json_key)) + } + _ => Err(anyhow::anyhow!( + "invalid aws-sm:// secret ref '{value}' — expected aws-sm://#" + )), + }) +} + +/// Resolve a `spec.secrets` value into the ECS-native `valueFrom` format ECS +/// actually requires. ECS's `valueFrom` requires the *full* ARN (not just a +/// secret name) whenever a JSON-key suffix is present, so an `aws-sm://` +/// secret-id that isn't already an ARN is resolved to its ARN via +/// `DescribeSecret` first. Values already in ECS-native format are passed +/// through unchanged. +pub async fn resolve_value_from( + sm: &aws_sdk_secretsmanager::Client, + value: &str, +) -> Result { + let Some(parsed) = parse_aws_sm_uri(value) else { + return Ok(value.to_string()); + }; + let (secret_id, json_key) = parsed?; + + let arn = if secret_id.starts_with("arn:") { + secret_id.to_string() + } else { + sm.describe_secret() + .secret_id(secret_id) + .send() + .await + .with_context(|| format!("failed to resolve secret '{secret_id}' to an ARN"))? + .arn() + .with_context(|| format!("secret '{secret_id}' has no ARN"))? + .to_string() + }; + Ok(format!("{arn}:{json_key}::")) +} + +/// Resolve a `spec.secrets` value to its plain string content, for callers +/// that need the actual secret value in-process (e.g. calling a third-party +/// API on the caller's behalf) rather than an ECS `valueFrom` reference. +/// Supports the same two forms as [`resolve_value_from`]: `aws-sm://...#...` +/// (fetched and JSON-key-extracted here), or a plain/ECS-native Secrets +/// Manager ARN — including one already carrying a `:::` suffix, +/// which `GetSecretValue` resolves natively. +pub async fn resolve_string(sm: &aws_sdk_secretsmanager::Client, value: &str) -> Result { + if let Some(parsed) = parse_aws_sm_uri(value) { + let (secret_id, json_key) = parsed?; + let secret_string = sm + .get_secret_value() + .secret_id(secret_id) + .send() + .await + .with_context(|| format!("failed to fetch secret '{secret_id}' from Secrets Manager"))? + .secret_string() + .with_context(|| format!("secret '{secret_id}' has no string value"))? + .to_string(); + let json: serde_json::Value = serde_json::from_str(&secret_string) + .with_context(|| format!("secret '{secret_id}' is not valid JSON"))?; + return json + .get(json_key) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + .with_context(|| format!("JSON key '{json_key}' not found in secret '{secret_id}'")); + } + + sm.get_secret_value() + .secret_id(value) + .send() + .await + .with_context(|| format!("failed to fetch secret '{value}' from Secrets Manager"))? + .secret_string() + .with_context(|| format!("secret '{value}' has no string value")) + .map(|v| v.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_aws_sm_uri_extracts_id_and_key() { + let (id, key) = parse_aws_sm_uri("aws-sm://oab/telegram/pahudxbot#TELEGRAM_BOT_TOKEN") + .unwrap() + .unwrap(); + assert_eq!(id, "oab/telegram/pahudxbot"); + assert_eq!(key, "TELEGRAM_BOT_TOKEN"); + } + + #[test] + fn parse_aws_sm_uri_rejects_missing_hash() { + assert!(parse_aws_sm_uri("aws-sm://oab/telegram/pahudxbot").unwrap().is_err()); + } + + #[test] + fn parse_aws_sm_uri_rejects_empty_parts() { + assert!(parse_aws_sm_uri("aws-sm://#key").unwrap().is_err()); + assert!(parse_aws_sm_uri("aws-sm://secret-id#").unwrap().is_err()); + } + + #[test] + fn parse_aws_sm_uri_returns_none_for_other_schemes() { + assert!(parse_aws_sm_uri("arn:aws:secretsmanager:us-east-1:123:secret:oab/x-AbCdEf").is_none()); + assert!(parse_aws_sm_uri("plain-secret-name").is_none()); + } +}