Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions operator/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
105 changes: 98 additions & 7 deletions operator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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 <file>`:

```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://<your-bucket>/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 (`<cloudMapNamespace>-<vpc-id>`, 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)
Expand All @@ -251,24 +287,38 @@ 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:
https://{api-id}.execute-api.{region}.amazonaws.com/prod/webhook/telegram
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
# `:<jsonKey>::` 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://<secret-id>#<json-key> 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; <secret-id> 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
Expand Down
36 changes: 27 additions & 9 deletions operator/src/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Secret> = 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 `:<jsonKey>::` suffix), or the same `aws-sm://<secret-id>#<json-key>`
// 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<Secret> = 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
Expand Down Expand Up @@ -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 {
Expand Down
113 changes: 113 additions & 0 deletions operator/src/ingress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
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<String, String>,
webhook_urls: &[(String, String)],
) -> Result<Option<String>> {
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}")
Expand Down Expand Up @@ -945,6 +1029,35 @@ mod tests {
assert!(has_stage_path_override(Some(&params)));
}

#[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!(
Expand Down
1 change: 1 addition & 0 deletions operator/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod create;
mod get;
mod delete;
mod ingress;
mod secrets;

use clap::{Parser, Subcommand};

Expand Down
Loading
Loading