Compare commits

..

6 Commits

Author SHA1 Message Date
Zakaria 6e0bb98844 feat(api): add ACM certificates to the cloud explorer
CI / checks (pull_request) Successful in 27m28s
Request public certificates (optionally exportable) or import your own, see
each domain's DNS validation records and what uses a certificate, download the
certificate and chain, export an exportable one with its encrypted key, and
revoke it. ListCertificates asks for every key type, since AWS otherwise
leaves EC certificates out.
2026-10-25 12:41:42 -04:00
Zakaria d4726950da Merge pull request 'fix(frontend): stop MSK breaking the Console Home resource count' (#22) from fix/console-home-resource-count into main
CI / checks (push) Successful in 27m6s
Reviewed-on: #22
2026-10-25 14:29:25 +00:00
Zakaria eeadeef664 fix(frontend): stop MSK breaking the Console Home resource count
CI / checks (pull_request) Successful in 26m59s
Console Home counted every available service through the generic resource
list, but MSK and MSK Connect are available through their own pre-SPI pages
with no adapter behind /resources, so each answered 501 and the Resources tile
read "Resource load failed". Service descriptors now say whether the generic
list serves them (listable, true when an adapter is registered), and Console
Home counts only those; the MSK cards still link to their pages.
2026-10-25 10:09:35 -04:00
Zakaria 9f1ea32183 Merge pull request 'docs: rebrand the README as TheCloud' (#21) from chore/readme-thecloud into main
CI / checks (push) Has been cancelled
Reviewed-on: #21
2026-10-25 14:00:14 +00:00
Zakaria 811791603c docs: rebrand the README as TheCloud
CI / checks (pull_request) Successful in 27m5s
Replace the Floci logo, screenshots and architecture diagram with TheCloud's
logo, a current screenshot and a redrawn diagram; say TheCloud in prose while
keeping literal identifiers (env vars, image and compose service names); point
the runtime setup at FlociClone; drop the upstream release and Docker badges
and the upstream community section; keep the credit to Floci under License.
2026-10-25 09:57:22 -04:00
Zakaria 67660858eb Merge pull request 'feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page' (#20) from feat/apigateway-v2 into main
CI / checks (push) Has been cancelled
Reviewed-on: #20
2026-10-25 13:49:09 +00:00
35 changed files with 1301 additions and 469 deletions
+32 -61
View File
@@ -1,26 +1,23 @@
<p align="center">
<img src="docs/images/floci-black.svg#gh-light-mode-only" alt="Floci UI" width="460" />
<img src="docs/images/floci-white.svg#gh-dark-mode-only" alt="Floci UI" width="460" />
<img src="docs/images/thecloud-logo.png" alt="TheCloud" width="160" />
</p>
<h1 align="center">TheCloud</h1>
<p align="center">
<strong>Any Cloud. Locally.</strong><br />
A local-first, cloud-aware runtime console for Floci and compatible local cloud emulators.
A self-hosted cloud console for local AWS, Azure and GCP runtimes.
</p>
<p align="center">
<a href="https://github.com/floci-io/floci-ui/releases/latest"><img src="https://img.shields.io/github/v/release/floci-io/floci-ui?label=latest%20release&color=blue" alt="Latest Release"></a>
<a href="https://github.com/floci-io/floci-ui/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/floci-io/floci-ui/ci.yml?branch=main&label=ci" alt="CI Status"></a>
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/pulls/floci/floci-ui?label=docker%20pulls" alt="Docker Pulls"></a>
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/image-size/floci/floci-ui/latest?label=image%20size" alt="Docker Image Size"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT"></a>
</p>
<p align="center">
<img src="docs/images/floci-ui-console.png" alt="Floci UI console" width="900" />
<img src="docs/images/thecloud-console.png" alt="TheCloud's Cloud Explorer showing S3 buckets and the selected bucket's details" width="900" />
</p>
Floci UI is the web console for the Floci ecosystem. The current app is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`. It renders only real data returned by local runtimes and explicit placeholders for work that is not wired yet.
TheCloud is an AWS-Console-style web console for locally running cloud runtimes. It is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`, and it renders only real data returned by the runtimes, with explicit placeholders for work that is not wired yet.
No fake resources, no demo rows, and no mock operational data are shown in normal mode.
@@ -42,9 +39,9 @@ Open [http://localhost:4500](http://localhost:4500).
## Exposing To The Internet
Floci UI requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts '<password>'`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`.
TheCloud requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts '<password>'`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`.
Put only Floci UI behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind Floci core (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. Set `FLOCI_UI_TRUSTED_PROXIES` to the proxy's IP or CIDR (comma-separated for several hops, e.g. Cloudflare's published ranges when it sits in front of Nginx Proxy Manager); `X-Forwarded-For` is ignored from any other peer, and without it sign-in throttling treats every visitor as the proxy.
Put only TheCloud's console behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind TheCloud's AWS runtime (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. Set `FLOCI_UI_TRUSTED_PROXIES` to the proxy's IP or CIDR (comma-separated for several hops, e.g. Cloudflare's published ranges when it sits in front of Nginx Proxy Manager); `X-Forwarded-For` is ignored from any other peer, and without it sign-in throttling treats every visitor as the proxy.
Set `FLOCI_UI_AUTH_MODE=oidc` to use an OIDC provider such as Authentik. Configure `FLOCI_UI_OIDC_ISSUER`, `FLOCI_UI_OIDC_CLIENT_ID`, `FLOCI_UI_OIDC_CLIENT_SECRET`, and `FLOCI_UI_PUBLIC_BASE_URL`; the callback is `${FLOCI_UI_PUBLIC_BASE_URL}/api/auth/oidc/callback`. OIDC mode also requires an allow-list: `FLOCI_UI_OIDC_ALLOWED_GROUPS` (matched against the `groups` claim, read from UserInfo when the ID token omits it) and/or `FLOCI_UI_OIDC_ALLOWED_EMAILS` (verified emails only). Set `FLOCI_UI_OIDC_ALLOW_ALL_USERS=true` only if every user of the identity provider should have console access.
@@ -97,6 +94,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
| Security | Certificate Manager / Certificates | Yes (list, create, delete, inspect) | No | No |
| Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No |
| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
@@ -110,13 +108,9 @@ Services marked `No` render as a disabled sidebar row whose tooltip carries the
server-supplied reason. Adding one is a catalog row in
`packages/api/src/cloud-spi/serviceCatalog.ts` plus an adapter — no frontend change.
<p align="center">
<img src="docs/images/floci-ui-console-azure.png" alt="Azure console home, showing services grouped by category with per-cloud naming and coming-soon reasons" width="900" />
</p>
Azure on the same build: the nav is grouped by category, `k8s Engine` is labelled
`AKS` for this provider, and every unavailable service carries a reason — Serverless
reads `coming soon` because the Floci-AZ runtime answers 501 for Azure Functions,
With Azure selected, the nav is grouped the same way, `k8s Engine` is labelled
`AKS` for that provider, and every unavailable service carries a reason: Serverless
reads `coming soon` because the Azure runtime answers 501 for Azure Functions,
even though an adapter is registered.
## Current Capability Snapshot
@@ -159,7 +153,7 @@ All three clouds, through the unified shell.
Current gaps:
- EKS and AKS are read-only. On AKS this is a runtime limit rather than a choice:
the shipped floci-az config runs AKS unmocked with no Docker socket to start k3s
the shipped Azure runtime (`floci-az`) config runs AKS unmocked with no Docker socket to start k3s
with, so a created cluster never leaves `provisioningState: Failed`.
</details>
@@ -169,7 +163,7 @@ Current gaps:
Relational and document database workflows across providers:
- AWS RDS: list, inspect, create, update, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). Updates use generic `PATCH /api/clouds/:cloud/services/:service/resources/:id` mapping to `ModifyDBInstance` for password rotation, IAM authentication, DB subnet group, VPC security groups, option group, and auto minor version upgrade. Instance class, storage, engine, and version are omitted from edit operations because the current local Floci RDS emulator does not support modifying them.
- AWS RDS: list, inspect, create, update, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). Updates use generic `PATCH /api/clouds/:cloud/services/:service/resources/:id` mapping to `ModifyDBInstance` for password rotation, IAM authentication, DB subnet group, VPC security groups, option group, and auto minor version upgrade. Instance class, storage, engine, and version are omitted from edit operations because TheCloud's RDS runtime does not support modifying them yet.
- AWS RDS Snapshots: account-scoped Snapshots tab listing DB snapshots and supporting snapshot creation.
- Azure Cosmos DB NoSQL: database, container, and document workflows.
- AWS DynamoDB: table management, item browsing, and Add record.
@@ -196,8 +190,8 @@ Frontend regression tests use mocked `/api/*` responses and need no running emul
Current gaps:
- AWS RDS snapshot creation: the Cloud Proxy operation is available, but the current Floci runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list.
- AWS RDS instance stop/start operations are not implemented in the current local Floci runtime.
- AWS RDS snapshot creation: the Cloud Proxy operation is available, but TheCloud's runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list.
- AWS RDS instance stop/start operations are not implemented in TheCloud's runtime yet.
</details>
@@ -280,7 +274,7 @@ Current gaps:
AWS SES email capture through the unified Cloud Explorer.
- Lists emails actually captured by Floci SES.
- Lists emails actually captured by TheCloud's SES.
- Filters by subject, sender, and recipient.
- Inspects sender, recipients, timestamp, and message type.
- Displays HTML in a sandboxed preview, text bodies, and captured raw MIME data.
@@ -297,10 +291,10 @@ aws ses send-email \
--endpoint-url http://localhost:4566 \
--from sender@example.test \
--destination 'ToAddresses=recipient@example.test' \
--message 'Subject={Data="Floci SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="<h1>Hello from Floci</h1><p>This should render in the SES preview.</p>",Charset=utf-8}}'
--message 'Subject={Data="TheCloud SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="<h1>Hello from TheCloud</h1><p>This should render in the SES preview.</p>",Charset=utf-8}}'
```
The email is captured by the local Floci runtime; it is not delivered externally. Open
The email is captured by TheCloud's runtime, and is delivered externally only when an SMTP relay is configured. Open
`/cloud-explorer/aws/email` and refresh the mailbox to inspect its Preview, Text, and
Raw views. You can also inspect the captured messages directly with:
@@ -328,7 +322,7 @@ AWS and GCP, both through the unified shell.
Current gaps:
- Azure Functions is registered but the Floci-AZ runtime answers 501 NotImplemented,
- Azure Functions is registered but the Azure runtime answers 501 NotImplemented,
so it reports `coming_soon` with that reason rather than appearing available.
- GCP Cloud Functions invoke is not wired yet; the capability is advertised as
`coming_soon` instead of being silently missing.
@@ -381,7 +375,7 @@ Current gaps:
## Product Direction
Floci UI is evolving toward a metadata-driven, cloud-aware console where one web app can render multiple local runtimes through the same shell.
TheCloud is a metadata-driven, cloud-aware console: one web app renders multiple local runtimes through the same shell.
The guiding rules are:
@@ -393,7 +387,7 @@ The guiding rules are:
## Architecture
![Floci Unified UI Multi-Cloud Architecture](docs/images/floci-unified-ui-architecture.png)
![TheCloud architecture: the console calls TheCloud's API, which dispatches through one proxy and an adapter registry to AWS, Azure and GCP adapters and their local runtimes](docs/images/thecloud-architecture.svg)
Short implementation notes live in [docs/implementation-notes.md](docs/implementation-notes.md).
@@ -435,9 +429,9 @@ Browser
Default compose stack:
- `floci-ui` on `http://localhost:4500`
- `floci-api` on `http://localhost:4501`
- `floci` on `http://localhost:4566`
- `floci-ui` (the console) on `http://localhost:4500`
- `floci-api` (TheCloud's API) on `http://localhost:4501`
- `floci` (TheCloud's AWS runtime) on `http://localhost:4566`
Start AWS-only:
@@ -467,7 +461,7 @@ Prerequisites:
- Node.js 20+
- pnpm 9+
- Bun
- A running local runtime: Floci core, and optionally Floci-AZ / Floci-GCP
- A running local runtime: TheCloud's AWS runtime, and optionally the Azure and GCP runtimes
Install dependencies:
@@ -483,29 +477,18 @@ cp .env.example packages/api/.env
Important: the API runs from `packages/api` and loads environment variables from `packages/api/.env`.
Start Floci AWS Core with Docker:
Start TheCloud's AWS runtime from its repository:
```bash
docker run -d --name floci \
-p 4566:4566 \
-v /var/run/docker.sock:/var/run/docker.sock \
-e FLOCI_DEFAULT_REGION=us-east-1 \
-u root \
floci/floci:latest
```
Or from a local clone:
```bash
git clone https://github.com/floci-io/floci.git ../floci
git clone https://gitea.zacksolutions.dev/Zakaria/FlociClone.git ../floci
cd ../floci
./mvnw clean quarkus:dev
docker compose up -d # or ./mvnw clean quarkus:dev with JDK 25
```
Optional local runtimes:
- Floci-AZ on `http://localhost:4577`
- Floci-GCP on `http://localhost:4588`
- Azure runtime (`floci-az`) on `http://localhost:4577`
- GCP runtime (`floci-gcp`) on `http://localhost:4588`
Start the UI stack:
@@ -614,18 +597,6 @@ When adding new UI surface:
- Keep placeholders explicit instead of inventing fake data.
- Update this README when the visible UI surface changes.
## Community Projects
Floci UI is the first-party console, but it is not the only one. The community builds
consoles for Floci too:
- [floci-dash](https://github.com/ofsazib/floci-dash) — an AWS-Console-style dashboard
for the Floci AWS runtime built on Cloudscape Design. It ships as a single Docker image
and includes an EC2 web terminal. It targets the AWS runtime only, while Floci UI also
covers Azure and GCP, so pick whichever fits your stack.
Building something for Floci? Open a PR to add it here.
## License
[MIT](LICENSE) — part of the [Floci](https://floci.io) ecosystem.
[MIT](LICENSE). TheCloud builds on the open-source [Floci](https://floci.io) project.
-200
View File
@@ -1,200 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
<defs>
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<style>
.st0 {
fill: #151515;
}
.st1 {
fill: #5559A7;
}
.st2 {
isolation: isolate;
}
.st3 {
fill: #151515;
}
.st4 {
fill: #262425;
}
#logoBuild {
transform-box: fill-box;
transform-origin: center;
animation: logoBreath 5s ease-in-out 2.7s infinite;
}
.logo-piece {
opacity: 0;
animation: reveal .5s ease forwards;
}
.cloud-a { animation-delay: .55s; }
.cloud-b { animation-delay: .75s; }
.word-a { animation-delay: 1.72s; }
.word-b { animation-delay: 1.92s; }
.word-c { animation-delay: 2.12s; }
.word-d { animation-delay: 2.32s; }
.guide-path {
fill: none;
opacity: 1;
stroke-opacity: .9;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
vector-effect: non-scaling-stroke;
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
guideFade .42s ease forwards;
}
.guide-dark {
stroke: #151515;
stroke-width: 2.6;
}
.guide-word {
stroke: #262425;
stroke-width: 2;
}
.guide-cloud-a { animation-delay: .08s, 1.25s; }
.guide-cloud-b { animation-delay: .24s, 1.45s; }
.guide-word-a { animation-delay: 1.18s, 2.28s; }
.guide-word-b { animation-delay: 1.36s, 2.48s; }
.guide-word-c { animation-delay: 1.54s, 2.68s; }
.guide-word-d { animation-delay: 1.72s, 2.88s; }
.dot-pop {
opacity: 0;
filter: url(#logoGlow);
transform: scale(.15);
transform-box: fill-box;
transform-origin: center;
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
softPulse 3.2s ease-in-out 2.2s infinite;
}
.dot-cloud { animation-delay: 1.45s, 3.1s; }
.dot-i { animation-delay: 2.62s, 3.45s; }
.cloud-shadow {
opacity: 0;
animation: shadowIn .8s ease 1.2s forwards;
}
.shine {
opacity: 0;
mix-blend-mode: screen;
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
}
@keyframes drawLine {
to { stroke-dashoffset: 0; }
}
@keyframes guideFade {
from { opacity: 1; stroke-opacity: .9; }
to { opacity: 0; stroke-opacity: 0; }
}
@keyframes reveal {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dotPop {
0% { opacity: 0; transform: scale(.15); }
62% { opacity: 1; transform: scale(1.18); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes softPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
@keyframes shadowIn {
from { opacity: 0; transform: translateY(4px) scale(.94); }
to { opacity: .14; transform: translateY(0) scale(1); }
}
@keyframes shineSweep {
0%, 18% { opacity: 0; transform: translateX(-220px); }
30% { opacity: .7; }
54% { opacity: 0; transform: translateX(560px); }
100% { opacity: 0; transform: translateX(560px); }
}
@keyframes logoBreath {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.008); }
}
@media (prefers-reduced-motion: reduce) {
#logoBuild,
#logoBuild *,
.shine {
animation: none !important;
opacity: 1;
stroke-dashoffset: 0;
transform: none;
}
.guide-path {
opacity: 0;
}
.shine {
opacity: 0;
}
}
</style>
</defs>
<g id="logoBuild">
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
<g id="finalWordmark" class="st2">
<g class="st2">
<path class="st4 logo-piece word-a" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="st4 logo-piece word-b" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="st4 logo-piece word-c" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="st4 logo-piece word-d" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
</g>
<g id="finalMark">
<path class="st0 logo-piece cloud-a" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01ZM44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<ellipse class="st1 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
</g>
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
<g id="drawGuides">
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
<path class="guide-path guide-dark guide-cloud-b" pathLength="1" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<path class="guide-path guide-word guide-word-a" pathLength="1" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="guide-path guide-word guide-word-b" pathLength="1" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="guide-path guide-word guide-word-c" pathLength="1" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="guide-path guide-word guide-word-d" pathLength="1" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

-200
View File
@@ -1,200 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
<defs>
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<style>
.st0 {
fill: #FFFFFF;
}
.st1 {
fill: #5559A7;
}
.st2 {
isolation: isolate;
}
.st3 {
fill: #FFFFFF;
}
.st4 {
fill: #FFFFFF;
}
#logoBuild {
transform-box: fill-box;
transform-origin: center;
animation: logoBreath 5s ease-in-out 2.7s infinite;
}
.logo-piece {
opacity: 0;
animation: reveal .5s ease forwards;
}
.cloud-a { animation-delay: .55s; }
.cloud-b { animation-delay: .75s; }
.word-a { animation-delay: 1.72s; }
.word-b { animation-delay: 1.92s; }
.word-c { animation-delay: 2.12s; }
.word-d { animation-delay: 2.32s; }
.guide-path {
fill: none;
opacity: 1;
stroke-opacity: .9;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
vector-effect: non-scaling-stroke;
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
guideFade .42s ease forwards;
}
.guide-dark {
stroke: #FFFFFF;
stroke-width: 2.6;
}
.guide-word {
stroke: #FFFFFF;
stroke-width: 2;
}
.guide-cloud-a { animation-delay: .08s, 1.25s; }
.guide-cloud-b { animation-delay: .24s, 1.45s; }
.guide-word-a { animation-delay: 1.18s, 2.28s; }
.guide-word-b { animation-delay: 1.36s, 2.48s; }
.guide-word-c { animation-delay: 1.54s, 2.68s; }
.guide-word-d { animation-delay: 1.72s, 2.88s; }
.dot-pop {
opacity: 0;
filter: url(#logoGlow);
transform: scale(.15);
transform-box: fill-box;
transform-origin: center;
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
softPulse 3.2s ease-in-out 2.2s infinite;
}
.dot-cloud { animation-delay: 1.45s, 3.1s; }
.dot-i { animation-delay: 2.62s, 3.45s; }
.cloud-shadow {
opacity: 0;
animation: shadowIn .8s ease 1.2s forwards;
}
.shine {
opacity: 0;
mix-blend-mode: screen;
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
}
@keyframes drawLine {
to { stroke-dashoffset: 0; }
}
@keyframes guideFade {
from { opacity: 1; stroke-opacity: .9; }
to { opacity: 0; stroke-opacity: 0; }
}
@keyframes reveal {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dotPop {
0% { opacity: 0; transform: scale(.15); }
62% { opacity: 1; transform: scale(1.18); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes softPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
@keyframes shadowIn {
from { opacity: 0; transform: translateY(4px) scale(.94); }
to { opacity: .14; transform: translateY(0) scale(1); }
}
@keyframes shineSweep {
0%, 18% { opacity: 0; transform: translateX(-220px); }
30% { opacity: .7; }
54% { opacity: 0; transform: translateX(560px); }
100% { opacity: 0; transform: translateX(560px); }
}
@keyframes logoBreath {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.008); }
}
@media (prefers-reduced-motion: reduce) {
#logoBuild,
#logoBuild *,
.shine {
animation: none !important;
opacity: 1;
stroke-dashoffset: 0;
transform: none;
}
.guide-path {
opacity: 0;
}
.shine {
opacity: 0;
}
}
</style>
</defs>
<g id="logoBuild">
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
<g id="finalWordmark" class="st2">
<g class="st2">
<path class="st4 logo-piece word-a" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="st4 logo-piece word-b" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="st4 logo-piece word-c" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="st4 logo-piece word-d" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
</g>
<g id="finalMark">
<path class="st0 logo-piece cloud-a" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01ZM44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<ellipse class="st1 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
</g>
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
<g id="drawGuides">
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
<path class="guide-path guide-dark guide-cloud-b" pathLength="1" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
<path class="guide-path guide-word guide-word-a" pathLength="1" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
<path class="guide-path guide-word guide-word-b" pathLength="1" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
<path class="guide-path guide-word guide-word-c" pathLength="1" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
<path class="guide-path guide-word guide-word-d" pathLength="1" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
</g>
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.8 KiB

+72
View File
@@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 620" width="960" height="620" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">TheCloud architecture</title>
<desc id="desc">The browser talks only to TheCloud's API, which dispatches each request through one proxy service and an adapter registry to a provider adapter, which calls the local AWS, Azure or GCP runtime with that cloud's real API.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#5b6473"/>
</marker>
</defs>
<rect width="960" height="620" rx="16" fill="#f7f4ee"/>
<text x="480" y="44" text-anchor="middle" font-size="24" font-weight="700" fill="#1d2330">TheCloud architecture</text>
<text x="480" y="70" text-anchor="middle" font-size="14" fill="#5b6473">One console for local AWS, Azure and GCP runtimes, using each cloud's real API</text>
<!-- Browser -->
<rect x="250" y="96" width="460" height="74" rx="10" fill="#ffffff" stroke="#c9b48a" stroke-width="2"/>
<text x="270" y="124" font-size="16" font-weight="700" fill="#1d2330">Console (React, port 4500)</text>
<text x="270" y="148" font-size="13" fill="#5b6473">Nav and pages rendered from each service's schema; calls only /api/*</text>
<line x1="480" y1="170" x2="480" y2="202" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<!-- API -->
<rect x="160" y="206" width="640" height="186" rx="10" fill="#ffffff" stroke="#1d2330" stroke-width="2"/>
<text x="180" y="234" font-size="16" font-weight="700" fill="#1d2330">TheCloud API (Bun and Hono, port 4501)</text>
<text x="180" y="256" font-size="13" fill="#5b6473">Sign-in, then the generic /api/clouds/:cloud/services/:service routes</text>
<rect x="180" y="272" width="280" height="50" rx="8" fill="#efe7d6"/>
<text x="196" y="294" font-size="14" font-weight="600" fill="#1d2330">CloudProxyService</text>
<text x="196" y="312" font-size="12" fill="#5b6473">One dispatcher for every request</text>
<rect x="500" y="272" width="280" height="50" rx="8" fill="#efe7d6"/>
<text x="516" y="294" font-size="14" font-weight="600" fill="#1d2330">Adapter registry and service catalog</text>
<text x="516" y="312" font-size="12" fill="#5b6473">Registered adapters decide what is shown</text>
<line x1="460" y1="297" x2="496" y2="297" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<text x="180" y="350" font-size="13" fill="#1d2330"><tspan font-weight="600">Schemas</tspan> describe fields, columns and capabilities; a capability</text>
<text x="180" y="370" font-size="13" fill="#1d2330">is only advertised when its adapter can perform it (checked by tests).</text>
<line x1="240" y1="392" x2="200" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="480" y1="392" x2="480" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="720" y1="392" x2="760" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<!-- Adapters -->
<rect x="60" y="442" width="270" height="60" rx="10" fill="#fff3e3" stroke="#e08a2c" stroke-width="2"/>
<text x="76" y="468" font-size="15" font-weight="700" fill="#a85a0c">AWS adapters</text>
<text x="76" y="488" font-size="12" fill="#5b6473">AWS SDK v3, one per service</text>
<rect x="345" y="442" width="270" height="60" rx="10" fill="#e8f0fd" stroke="#2f6fd6" stroke-width="2"/>
<text x="361" y="468" font-size="15" font-weight="700" fill="#1f4fa3">Azure adapters</text>
<text x="361" y="488" font-size="12" fill="#5b6473">Azure runtime client</text>
<rect x="630" y="442" width="270" height="60" rx="10" fill="#e8f6ec" stroke="#2f9a55" stroke-width="2"/>
<text x="646" y="468" font-size="15" font-weight="700" fill="#1f7140">GCP adapters</text>
<text x="646" y="488" font-size="12" fill="#5b6473">GCP runtime client</text>
<line x1="195" y1="502" x2="195" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="480" y1="502" x2="480" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="765" y1="502" x2="765" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
<!-- Runtimes -->
<rect x="60" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
<text x="76" y="558" font-size="14" font-weight="700" fill="#f7f4ee">TheCloud AWS runtime</text>
<text x="76" y="578" font-size="12" fill="#c9cfd8">port 4566</text>
<rect x="345" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
<text x="361" y="558" font-size="14" font-weight="700" fill="#f7f4ee">Azure runtime</text>
<text x="361" y="578" font-size="12" fill="#c9cfd8">port 4577, optional</text>
<rect x="630" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
<text x="646" y="558" font-size="14" font-weight="700" fill="#f7f4ee">GCP runtime</text>
<text x="646" y="578" font-size="12" fill="#c9cfd8">port 4588, optional</text>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+1
View File
@@ -10,6 +10,7 @@
"test": "FLOCI_UI_ADMIN_USER=admin FLOCI_UI_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$I5n/tqYyZO0S+4z++KcAlpAJVu442Ivi7KxPl2CXoO4$mRSyN9R5ACFosgnfnd6S5ePoKrz2XBfVf4DTfG7T6LQ' FLOCI_UI_SESSION_SECRET=01234567890123456789012345678901 FLOCI_UI_SECURE_COOKIES=false bun test"
},
"dependencies": {
"@aws-sdk/client-acm": "^3.1146.0",
"@aws-sdk/client-api-gateway": "^3.1076.0",
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
"@aws-sdk/client-appconfig": "^3.1076.0",
@@ -0,0 +1,164 @@
import {describe, expect, test} from 'bun:test'
import {
type ACMClient,
DeleteCertificateCommand,
DescribeCertificateCommand,
ExportCertificateCommand,
GetCertificateCommand,
ImportCertificateCommand,
ListCertificatesCommand,
RequestCertificateCommand,
RevokeCertificateCommand,
} from '@aws-sdk/client-acm'
import {AwsCertificatesAdapter} from './AwsCertificatesAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubAcm(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as ACMClient
return {client, sent}
}
function awsError(name: string, status = 400): Error {
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
}
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
const ARN = `arn:aws:acm:us-east-1:000000000000:certificate/${ID}`
const detail = {
CertificateArn: ARN,
DomainName: 'notes.thecloud.localhost',
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost'],
DomainValidationOptions: [{
DomainName: 'notes.thecloud.localhost',
ValidationStatus: 'SUCCESS',
ValidationMethod: 'DNS',
ResourceRecord: {Name: '_a1.notes.thecloud.localhost.', Type: 'CNAME', Value: '_e8.acm-validations.aws.'},
}],
Status: 'ISSUED',
Type: 'AMAZON_ISSUED',
KeyAlgorithm: 'RSA-2048',
Issuer: 'CN=Floci Local CA',
CreatedAt: new Date('2026-10-25T16:28:12Z'),
NotAfter: new Date('2027-10-25T16:28:12Z'),
InUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1'],
Options: {Export: 'ENABLED', CertificateTransparencyLoggingPreference: 'ENABLED'},
}
function adapterWith(handler: (command: object) => SendResult | Promise<SendResult>) {
const stub = stubAcm(handler)
return {...stub, adapter: new AwsCertificatesAdapter(stub.client, '000000000000', 'us-east-1')}
}
describe('AwsCertificatesAdapter', () => {
test('lists every key type across pages and searches the alternative names too', async () => {
const {adapter, sent} = adapterWith((command) => {
if (!(command instanceof ListCertificatesCommand)) return {}
return command.input.NextToken
? {CertificateSummaryList: [{CertificateArn: ARN.replace(ID, '11111111-2222-3333-4444-555555555555'), DomainName: 'shop.test', Type: 'IMPORTED', Status: 'ISSUED', InUse: false}]}
: {CertificateSummaryList: [{CertificateArn: ARN, DomainName: 'notes.thecloud.localhost', SubjectAlternativeNameSummaries: ['notes.thecloud.localhost', 'api.thecloud.localhost'], Type: 'AMAZON_ISSUED', Status: 'ISSUED', KeyAlgorithm: 'EC-prime256v1', InUse: true}], NextToken: 'page-2'}
})
const all = await adapter.list()
expect(all.map((certificate) => [certificate.id, certificate.name, certificate.metadata.certificateType, certificate.metadata.inUse])).toEqual([
[ID, 'notes.thecloud.localhost', 'Amazon issued', 'Yes'],
['11111111-2222-3333-4444-555555555555', 'shop.test', 'Imported', 'No'],
])
// Without Includes, AWS leaves EC certificates out of the list.
expect((sent[0] as ListCertificatesCommand).input.Includes?.keyTypes).toContain('EC_prime256v1')
expect((await adapter.list({search: 'api.thecloud'})).map((certificate) => certificate.id)).toEqual([ID])
})
test('describes a certificate with its users and the DNS records validation asks for', async () => {
const {adapter, sent} = adapterWith(() => ({Certificate: detail}))
const certificate = await adapter.get(ID)
expect((sent[0] as DescribeCertificateCommand).input.CertificateArn).toBe(ARN)
expect(certificate?.metadata).toMatchObject({
inUse: 'Yes',
inUseBy: detail.InUseBy,
exportable: true,
issuer: 'CN=Floci Local CA',
notAfter: '2027-10-25T16:28:12.000Z',
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
})
})
test('a missing certificate is null, and an id that is not a UUID never reaches ACM', async () => {
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceNotFoundException') })
expect(await adapter.get(ID)).toBeNull()
await expect(adapter.get('../other')).rejects.toBeInstanceOf(ValidationError)
expect(sent).toHaveLength(1)
})
test('requests a certificate for the domain and its extra names', async () => {
const {adapter, sent} = adapterWith((command) => command instanceof RequestCertificateCommand ? {CertificateArn: ARN} : {Certificate: detail})
const created = await adapter.create({values: {source: 'request', domainName: 'Notes.TheCloud.localhost', subjectAlternativeNames: 'api.thecloud.localhost\nnotes.thecloud.localhost\n*.thecloud.localhost', keyAlgorithm: 'EC_prime256v1', export: 'ENABLED'}})
expect(created.id).toBe(ID)
expect((sent[0] as RequestCertificateCommand).input).toEqual({
DomainName: 'notes.thecloud.localhost',
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost', '*.thecloud.localhost'],
ValidationMethod: 'DNS',
KeyAlgorithm: 'EC_prime256v1',
Options: {Export: 'ENABLED'},
})
})
test('refuses a request ACM would reject before calling it', async () => {
const {adapter, sent} = adapterWith(() => ({}))
await expect(adapter.create({values: {source: 'request', domainName: 'not a domain'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', subjectAlternativeNames: 'b..test'}})).rejects.toThrow('b..test is not a domain name')
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', keyAlgorithm: 'RSA_8192'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {source: 'copy', domainName: 'a.test'}})).rejects.toBeInstanceOf(ValidationError)
expect(sent).toHaveLength(0)
})
test('imports PEM text as bytes, with the chain when given', async () => {
const {adapter, sent} = adapterWith((command) => command instanceof ImportCertificateCommand ? {CertificateArn: ARN} : {Certificate: {...detail, Type: 'IMPORTED'}})
const certificate = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----'
const privateKey = '-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----'
const created = await adapter.create({values: {source: 'import', certificate, privateKey, certificateChain: certificate}})
expect(created.metadata.certificateType).toBe('Imported')
const input = (sent[0] as ImportCertificateCommand).input
expect(new TextDecoder().decode(input.Certificate)).toBe(certificate)
expect(new TextDecoder().decode(input.PrivateKey)).toBe(privateKey)
expect(input.CertificateChain).toBeDefined()
await expect(adapter.create({values: {source: 'import', certificate, privateKey: 'secret'}})).rejects.toThrow('privateKey must be PEM text')
})
test('downloads, exports and revokes by the rebuilt ARN', async () => {
const {adapter, sent} = adapterWith((command) => {
if (command instanceof GetCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN'}
if (command instanceof ExportCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN', PrivateKey: 'ENCRYPTED'}
if (command instanceof RevokeCertificateCommand) return {CertificateArn: ARN}
return {Certificate: {...detail, Status: 'REVOKED', RevocationReason: 'SUPERSEDED'}}
})
expect(await adapter.getCertificatePem(ID)).toEqual({certificate: 'CERT', certificateChain: 'CHAIN'})
expect(await adapter.exportCertificate(ID, 'open-sesame')).toEqual({certificate: 'CERT', certificateChain: 'CHAIN', privateKey: 'ENCRYPTED'})
expect(new TextDecoder().decode((sent[1] as ExportCertificateCommand).input.Passphrase)).toBe('open-sesame')
const revoked = await adapter.revokeCertificate(ID, 'SUPERSEDED')
expect(revoked.status).toBe('REVOKED')
expect((sent[2] as RevokeCertificateCommand).input).toEqual({CertificateArn: ARN, RevocationReason: 'SUPERSEDED'})
await expect(adapter.exportCertificate(ID, 'abc')).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.exportCertificate(ID, 'has$dollar')).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.revokeCertificate(ID, 'BORED' as never)).rejects.toBeInstanceOf(ValidationError)
})
test('deletes by ARN and lets ACM refuse a certificate that is in use', async () => {
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceInUseException') })
await expect(adapter.delete(ID)).rejects.toThrow('ResourceInUseException')
expect((sent[0] as DeleteCertificateCommand).input.CertificateArn).toBe(ARN)
})
})
@@ -0,0 +1,290 @@
import {
type ACMClient,
type CertificateDetail,
type CertificateSummary,
DeleteCertificateCommand,
DescribeCertificateCommand,
ExportCertificateCommand,
GetCertificateCommand,
ImportCertificateCommand,
type KeyAlgorithm,
ListCertificatesCommand,
RequestCertificateCommand,
RevokeCertificateCommand,
} from '@aws-sdk/client-acm'
import {
awsCertificatesSchema,
CERTIFICATE_DOMAIN_PATTERN,
CERTIFICATE_KEY_ALGORITHMS,
CERTIFICATE_REVOCATION_REASONS,
CERTIFICATE_VALIDATION_METHODS,
} from '../cloud-spi/certificatesSchema'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import type {
CertificatePem,
CertificateRevocationReason,
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ExportedCertificate,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/**
* ListCertificates returns only RSA certificates unless asked for other key
* types, so a list without this would silently leave out every EC certificate.
*/
const ALL_KEY_TYPES: KeyAlgorithm[] = ['RSA_1024', 'RSA_2048', 'RSA_3072', 'RSA_4096', 'EC_prime256v1', 'EC_secp384r1', 'EC_secp521r1']
/** ACM's limit on names per certificate, the domain name included. */
const MAX_NAMES = 100
const CERTIFICATE_TYPE_LABEL: Record<string, string> = {AMAZON_ISSUED: 'Amazon issued', IMPORTED: 'Imported', PRIVATE: 'Private'}
/**
* Talks to AWS Certificate Manager, verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - Requested certificates are signed by the runtime's own local CA and issued at
* once (its validation wait defaults to 0), but DescribeCertificate still lists
* the CNAME records DNS validation would ask for, as AWS does.
* - DeleteCertificate refuses with ResourceInUseException while a load balancer,
* distribution or API domain uses the certificate, as on AWS.
*
* Resources are keyed by the certificate's UUID, the last part of its ARN, because
* an ARN's slash cannot travel in a URL path segment; the ARN is rebuilt from the
* account and region this adapter serves.
*/
export class AwsCertificatesAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'certificates' as const
constructor(
private readonly acm: ACMClient,
private readonly accountId: string,
private readonly region = process.env.AWS_REGION || 'us-east-1',
) {}
schema(): ServiceSchema {
return awsCertificatesSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const summaries: CertificateSummary[] = []
let next: string | undefined
do {
const res = await this.acm.send(new ListCertificatesCommand({Includes: {keyTypes: ALL_KEY_TYPES}, MaxItems: 1000, ...(next ? {NextToken: next} : {})}))
summaries.push(...(res.CertificateSummaryList ?? []))
next = res.NextToken
} while (next)
const search = query.search?.trim().toLowerCase()
return summaries.map((summary) => this.fromSummary(summary)).filter((certificate) => !search
|| certificate.name.toLowerCase().includes(search)
|| certificate.id.includes(search)
|| (certificate.metadata.subjectAlternativeNames as string[]).some((name) => name.toLowerCase().includes(search)))
}
async get(id: string): Promise<CloudResource | null> {
try {
const res = await this.acm.send(new DescribeCertificateCommand({CertificateArn: this.arn(id)}))
return res.Certificate ? this.fromDetail(res.Certificate) : null
} catch (error) {
if (errorName(error) === 'ResourceNotFoundException') return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const source = input.values.source ?? 'request'
const arn = source === 'import' ? await this.importCertificate(input.values)
: source === 'request' ? await this.requestCertificate(input.values)
: null
if (arn === null) throw new ValidationError('source must be request or import')
const created = await this.get(idFromArn(arn))
if (!created) throw new RuntimeError(`ACM did not return the new certificate ${arn}`)
return created
}
async delete(id: string): Promise<void> {
await this.acm.send(new DeleteCertificateCommand({CertificateArn: this.arn(id)}))
}
async getCertificatePem(id: string): Promise<CertificatePem> {
const res = await this.acm.send(new GetCertificateCommand({CertificateArn: this.arn(id)}))
if (!res.Certificate) throw new RuntimeError('ACM did not return the certificate')
return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null}
}
/**
* AWS exports only private certificates and public ones requested as
* exportable; the private key comes back encrypted with the passphrase.
*/
async exportCertificate(id: string, passphrase: string): Promise<ExportedCertificate> {
if (typeof passphrase !== 'string' || passphrase.length < 4 || passphrase.length > 128) {
throw new ValidationError('passphrase must be 4 to 128 characters')
}
if (/[#$%]/.test(passphrase)) throw new ValidationError('passphrase must not contain #, $ or %')
const res = await this.acm.send(new ExportCertificateCommand({CertificateArn: this.arn(id), Passphrase: new TextEncoder().encode(passphrase)}))
if (!res.Certificate || !res.PrivateKey) throw new RuntimeError('ACM did not return the exported certificate')
return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null, privateKey: res.PrivateKey}
}
async revokeCertificate(id: string, reason: CertificateRevocationReason): Promise<CloudResource> {
if (!(CERTIFICATE_REVOCATION_REASONS as readonly string[]).includes(reason)) {
throw new ValidationError(`reason must be one of ${CERTIFICATE_REVOCATION_REASONS.join(', ')}`)
}
await this.acm.send(new RevokeCertificateCommand({CertificateArn: this.arn(id), RevocationReason: reason}))
const revoked = await this.get(id)
if (!revoked) throw new RuntimeError(`ACM did not return the revoked certificate ${id}`)
return revoked
}
private async requestCertificate(values: Record<string, unknown>): Promise<string> {
const domainName = text(values.domainName).toLowerCase()
if (!domainName) throw new ValidationError('domainName is required')
const pattern = new RegExp(CERTIFICATE_DOMAIN_PATTERN)
if (!pattern.test(domainName)) throw new ValidationError('domainName must be a domain name such as example.com or *.example.com')
const alternatives = [...new Set(text(values.subjectAlternativeNames).split(/[\s,]+/).map((name) => name.toLowerCase()).filter((name) => name && name !== domainName))]
const invalid = alternatives.find((name) => !pattern.test(name))
if (invalid) throw new ValidationError(`${invalid} is not a domain name`)
if (alternatives.length + 1 > MAX_NAMES) throw new ValidationError(`A certificate covers at most ${MAX_NAMES} names`)
const validationMethod = oneOf(values.validationMethod, CERTIFICATE_VALIDATION_METHODS, 'validationMethod')
const keyAlgorithm = oneOf(values.keyAlgorithm, CERTIFICATE_KEY_ALGORITHMS, 'keyAlgorithm')
const exportOption = oneOf(values.export, ['DISABLED', 'ENABLED'] as const, 'export')
const res = await this.acm.send(new RequestCertificateCommand({
DomainName: domainName,
...(alternatives.length > 0 ? {SubjectAlternativeNames: [domainName, ...alternatives]} : {}),
ValidationMethod: validationMethod,
KeyAlgorithm: keyAlgorithm,
Options: {Export: exportOption},
}))
if (!res.CertificateArn) throw new RuntimeError('ACM did not return the requested certificate')
return res.CertificateArn
}
private async importCertificate(values: Record<string, unknown>): Promise<string> {
const certificate = pem(values.certificate, 'certificate', 'CERTIFICATE')
const privateKey = pem(values.privateKey, 'privateKey', 'PRIVATE KEY')
const chain = text(values.certificateChain)
if (chain) pem(chain, 'certificateChain', 'CERTIFICATE')
const encode = (value: string) => new TextEncoder().encode(value)
const res = await this.acm.send(new ImportCertificateCommand({
Certificate: encode(certificate),
PrivateKey: encode(privateKey),
...(chain ? {CertificateChain: encode(chain)} : {}),
}))
if (!res.CertificateArn) throw new RuntimeError('ACM did not return the imported certificate')
return res.CertificateArn
}
private arn(id: string): string {
if (!/^[0-9a-f-]{36}$/i.test(id)) throw new ValidationError('certificate id must be the UUID at the end of its ARN')
return `arn:aws:acm:${this.region}:${this.accountId}:certificate/${id}`
}
private fromSummary(summary: CertificateSummary): CloudResource {
const arn = summary.CertificateArn ?? ''
return {
id: idFromArn(arn),
name: summary.DomainName ?? '',
cloud: 'aws',
service: 'certificates',
type: 'certificate',
region: arn.split(':')[3] || this.region,
createdAt: iso(summary.CreatedAt ?? summary.ImportedAt),
status: summary.Status ?? null,
metadata: {
arn,
certificateType: CERTIFICATE_TYPE_LABEL[summary.Type ?? ''] ?? summary.Type ?? null,
inUse: summary.InUse ? 'Yes' : 'No',
keyAlgorithm: summary.KeyAlgorithm ?? null,
notAfter: iso(summary.NotAfter),
subjectAlternativeNames: summary.SubjectAlternativeNameSummaries ?? [],
},
}
}
private fromDetail(detail: CertificateDetail): CloudResource {
const arn = detail.CertificateArn ?? ''
const inUseBy = detail.InUseBy ?? []
return {
id: idFromArn(arn),
name: detail.DomainName ?? '',
cloud: 'aws',
service: 'certificates',
type: 'certificate',
region: arn.split(':')[3] || this.region,
createdAt: iso(detail.CreatedAt ?? detail.ImportedAt),
status: detail.Status ?? null,
metadata: {
arn,
certificateType: CERTIFICATE_TYPE_LABEL[detail.Type ?? ''] ?? detail.Type ?? null,
inUse: inUseBy.length > 0 ? 'Yes' : 'No',
inUseBy,
keyAlgorithm: detail.KeyAlgorithm ?? null,
signatureAlgorithm: detail.SignatureAlgorithm ?? null,
notBefore: iso(detail.NotBefore),
notAfter: iso(detail.NotAfter),
issuedAt: iso(detail.IssuedAt),
importedAt: iso(detail.ImportedAt),
revokedAt: iso(detail.RevokedAt),
revocationReason: detail.RevocationReason ?? null,
failureReason: detail.FailureReason ?? null,
subject: detail.Subject ?? null,
issuer: detail.Issuer ?? null,
serial: detail.Serial ?? null,
subjectAlternativeNames: detail.SubjectAlternativeNames ?? [],
exportable: detail.Options?.Export === 'ENABLED',
transparencyLogging: detail.Options?.CertificateTransparencyLoggingPreference ?? null,
renewalEligibility: detail.RenewalEligibility ?? null,
domainValidations: (detail.DomainValidationOptions ?? []).map((validation) => ({
domainName: validation.DomainName ?? '',
status: validation.ValidationStatus ?? null,
method: validation.ValidationMethod ?? null,
record: validation.ResourceRecord
? {name: validation.ResourceRecord.Name ?? '', type: validation.ResourceRecord.Type ?? 'CNAME', value: validation.ResourceRecord.Value ?? ''}
: null,
})),
},
}
}
}
/** `arn:aws:acm:us-east-1:000000000000:certificate/<uuid>` — the UUID is what the console keys on. */
function idFromArn(arn: string): string {
return arn.slice(arn.lastIndexOf('/') + 1)
}
function iso(value: Date | undefined): string | null {
return value ? value.toISOString() : null
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function pem(value: unknown, field: string, label: string): string {
const body = text(value)
if (!body) throw new ValidationError(`${field} is required`)
if (!new RegExp(`-----BEGIN [A-Z ]*${label}-----`).test(body)) {
throw new ValidationError(`${field} must be PEM text starting with -----BEGIN ${label}-----`)
}
return body
}
function oneOf<T extends readonly string[]>(value: unknown, allowed: T, field: string): T[number] {
const raw = text(value)
if (!raw) return allowed[0] as T[number]
if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
return raw as T[number]
}
function errorName(error: unknown): string | undefined {
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
}
+3
View File
@@ -34,6 +34,7 @@ import { LightsailClient } from "@aws-sdk/client-lightsail";
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
import { Route53Client } from "@aws-sdk/client-route-53";
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
import { ACMClient } from "@aws-sdk/client-acm";
const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1";
@@ -99,6 +100,7 @@ export type AwsClients = {
cognito: CognitoIdentityProviderClient;
route53: Route53Client;
cloudfront: CloudFrontClient;
acm: ACMClient;
};
export type AwsClientName = keyof AwsClients;
@@ -148,6 +150,7 @@ function buildClients(accountId: string): AwsClients {
cognito: new CognitoIdentityProviderClient(base),
route53: new Route53Client(base),
cloudfront: new CloudFrontClient(base),
acm: new ACMClient(base),
};
}
@@ -0,0 +1,154 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/** A domain name, optionally a wildcard (`*.example.com`), as RequestCertificate accepts it. */
export const CERTIFICATE_DOMAIN_PATTERN = '^(\\*\\.)?(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$'
/** The key algorithms RequestCertificate accepts for a public certificate. */
export const CERTIFICATE_KEY_ALGORITHMS = ['RSA_2048', 'EC_prime256v1', 'EC_secp384r1'] as const
export const CERTIFICATE_VALIDATION_METHODS = ['DNS', 'EMAIL'] as const
/** RevokeCertificate's reasons, as the API names them. */
export const CERTIFICATE_REVOCATION_REASONS = [
'UNSPECIFIED',
'KEY_COMPROMISE',
'CA_COMPROMISE',
'AFFILIATION_CHANGED',
'SUPERSEDED',
'CESSATION_OF_OPERATION',
'PRIVILEGE_WITHDRAWN',
] as const
const certificateColumns: TableColumnSchema[] = [
{name: 'name', label: 'Domain name'},
{name: 'status', label: 'Status', format: 'badge'},
{name: 'certificateType', label: 'Type', path: 'metadata.certificateType'},
{name: 'inUse', label: 'In use', path: 'metadata.inUse'},
{name: 'keyAlgorithm', label: 'Key algorithm', path: 'metadata.keyAlgorithm'},
{name: 'notAfter', label: 'Expires', path: 'metadata.notAfter', format: 'datetime', emptyText: '—'},
{name: 'id', label: 'Certificate ID', format: 'code'},
]
const certificateFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
const requested = {field: 'source', values: ['request']}
const imported = {field: 'source', values: ['import']}
export function awsCertificatesSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'certificates',
displayName: 'AWS Certificate Manager',
fields: [
{
name: 'source',
label: 'Certificate type',
type: 'select',
required: true,
defaultValue: 'request',
options: [
{label: 'Request a public certificate', value: 'request'},
{label: 'Import a certificate', value: 'import'},
],
span: true,
},
{
name: 'domainName',
label: 'Domain name',
type: 'text',
required: false,
requiredWhen: {field: 'source', equals: 'request'},
visibleWhen: requested,
description: 'For example notes.thecloud.localhost, or *.thecloud.localhost for every subdomain. TheCloud signs it with its own local certificate authority.',
validation: {pattern: CERTIFICATE_DOMAIN_PATTERN, message: 'Use a domain name such as example.com or *.example.com'},
},
{
name: 'subjectAlternativeNames',
label: 'Additional names',
type: 'textarea',
required: false,
visibleWhen: requested,
description: 'Other names the certificate covers, one per line.',
},
{
name: 'validationMethod',
label: 'Validation method',
type: 'select',
required: false,
defaultValue: 'DNS',
visibleWhen: requested,
description: 'How ACM proves you own the domain. TheCloud issues the certificate without checking, and still shows the DNS records AWS would ask for.',
options: CERTIFICATE_VALIDATION_METHODS.map((value) => ({label: value, value})),
},
{
name: 'keyAlgorithm',
label: 'Key algorithm',
type: 'select',
required: false,
defaultValue: 'RSA_2048',
visibleWhen: requested,
options: CERTIFICATE_KEY_ALGORITHMS.map((value) => ({label: value.replace('_', ' '), value})),
},
{
name: 'export',
label: 'Exportable',
type: 'select',
required: false,
defaultValue: 'DISABLED',
visibleWhen: requested,
description: 'An exportable certificate can be downloaded with its private key, to use on servers outside AWS. This is chosen once and cannot be changed later.',
options: [
{label: 'No', value: 'DISABLED'},
{label: 'Yes, allow export', value: 'ENABLED'},
],
},
{
name: 'certificate',
label: 'Certificate body (PEM)',
type: 'textarea',
required: false,
requiredWhen: {field: 'source', equals: 'import'},
visibleWhen: imported,
span: true,
description: 'Starts with -----BEGIN CERTIFICATE-----.',
},
{
name: 'privateKey',
label: 'Private key (PEM)',
type: 'textarea',
required: false,
requiredWhen: {field: 'source', equals: 'import'},
visibleWhen: imported,
span: true,
description: 'The unencrypted key that matches the certificate.',
},
{
name: 'certificateChain',
label: 'Certificate chain (PEM)',
type: 'textarea',
required: false,
visibleWhen: imported,
span: true,
description: 'The intermediate certificates, if the certificate is not self-signed.',
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List certificates', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Request or import a certificate', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete certificate', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect certificate', enabled: true, status: 'available', runtimeRequired: true},
],
certificateActions: [
{name: 'download', label: 'Download the certificate and chain', enabled: true, status: 'available', runtimeRequired: true},
{name: 'export', label: 'Export with the private key', enabled: true, status: 'available', runtimeRequired: true},
{name: 'revoke', label: 'Revoke certificate', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: certificateFilters,
columns: certificateColumns,
}
}
export function certificatesSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsCertificatesSchema() : null
}
@@ -239,6 +239,13 @@ export const SERVICE_CATALOG = {
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
routeByCloud: {aws: '/secretsmanager'},
},
certificates: {
displayName: 'Certificates',
displayNameByCloud: {aws: 'Certificate Manager'},
iconKey: 'certificates',
group: 'Security',
order: 35,
},
cdn: {
displayName: 'CDN',
displayNameByCloud: {aws: 'CloudFront'},
+30
View File
@@ -44,6 +44,11 @@ export interface CloudServiceDescriptor {
iconKey: string
group: ServiceGroup
order: number
/**
* Whether the generic resource list serves it. False for a service still on its
* own pre-SPI page (MSK, MSK Connect): available, but not countable.
*/
listable: boolean
}
export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
@@ -181,6 +186,7 @@ export type UserPoolActionName =
| 'describeClient'
| 'updateClientOAuth'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type CertificateActionName = 'download' | 'export' | 'revoke'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type HttpApiActionName =
| 'describe'
@@ -240,6 +246,7 @@ export interface ServiceSchema {
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
distributionActions?: CapabilitySchema<DistributionActionName>[]
certificateActions?: CapabilitySchema<CertificateActionName>[]
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
@@ -510,6 +517,26 @@ export interface AlarmHistoryItem {
summary: string
}
/** A certificate and its chain as PEM text; never the private key. */
export interface CertificatePem {
certificate: string
certificateChain: string | null
}
/** An exported certificate: the private key is PEM, encrypted with the caller's passphrase. */
export interface ExportedCertificate extends CertificatePem {
privateKey: string
}
export type CertificateRevocationReason =
| 'UNSPECIFIED'
| 'KEY_COMPROMISE'
| 'CA_COMPROMISE'
| 'AFFILIATION_CHANGED'
| 'SUPERSEDED'
| 'CESSATION_OF_OPERATION'
| 'PRIVILEGE_WITHDRAWN'
export interface DistributionInvalidation {
id: string
status: string
@@ -1177,6 +1204,9 @@ export interface CloudServiceAdapter {
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
getCertificatePem?(certificateId: string): Promise<CertificatePem>
exportCertificate?(certificateId: string, passphrase: string): Promise<ExportedCertificate>
revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource>
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
/** Clears the provider's locally captured email inbox, if it exposes one. */
clearEmailInbox?(): Promise<void>
+17 -1
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/**
* Guards the schema-to-implementation contract for every registered adapter.
@@ -75,6 +75,12 @@ const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof Cloud
createInvalidation: 'createDistributionInvalidation',
}
const METHOD_FOR_CERTIFICATE_ACTION: Record<CertificateActionName, keyof CloudServiceAdapter> = {
download: 'getCertificatePem',
export: 'exportCertificate',
revoke: 'revokeCertificate',
}
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
updateUser: 'updateUserPoolUser',
setUserGroups: 'setUserPoolUserGroups',
@@ -188,6 +194,15 @@ describe('registered adapters honour their schema', () => {
).toBe('function')
}
const certificateClaims = (adapter.schema().capabilities?.certificateActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of certificateClaims) {
const method = METHOD_FOR_CERTIFICATE_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of distributionClaims) {
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
@@ -210,6 +225,7 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.kubernetesActions ?? []),
...(adapter.schema().capabilities?.userPoolActions ?? []),
...(adapter.schema().capabilities?.distributionActions ?? []),
...(adapter.schema().capabilities?.certificateActions ?? []),
...(adapter.schema().capabilities?.monitoringActions ?? []),
...(adapter.schema().capabilities?.httpApiActions ?? []),
...(adapter.schema().capabilities?.collectionActions ?? []),
+2
View File
@@ -50,6 +50,7 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter'
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
@@ -103,6 +104,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsUserPoolsAdapter(clients.cognito),
new AwsDnsAdapter(clients.route53),
new AwsCdnAdapter(clients.cloudfront, clients.s3),
new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)),
new AwsMonitoringAdapter(clients.cloudwatch),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
+41
View File
@@ -18,6 +18,7 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema'
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
@@ -823,6 +824,34 @@ describe('cloud schema routes', () => {
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
})
test('downloads, exports and revokes a certificate through the ACM adapter', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'certificates',
schema: awsCertificatesSchema,
getCertificatePem: async (id) => {
calls.push(['pem', id])
return {certificate: 'CERT', certificateChain: null}
},
exportCertificate: async (id, passphrase) => {
calls.push(['export', id, passphrase])
return {certificate: 'CERT', certificateChain: null, privateKey: 'KEY'}
},
revokeCertificate: async (id, reason) => {
calls.push(['revoke', id, reason])
return {id, name: 'a.test', cloud: 'aws', service: 'certificates', type: 'certificate', region: null, createdAt: null, status: 'REVOKED', metadata: {}}
},
})])
const json = (body: unknown) => ({method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
const base = '/api/clouds/aws/services/certificates/resources/c1'
expect(await (await app.request(`${base}/pem`)).json()).toEqual({certificate: 'CERT', certificateChain: null})
expect((await (await app.request(`${base}/export`, json({passphrase: 'secret'}))).json()).privateKey).toBe('KEY')
const revoked = await app.request(`${base}/revoke`, json({}))
expect((await revoked.json()).status).toBe('REVOKED')
expect(calls).toEqual([['pem', 'c1'], ['export', 'c1', 'secret'], ['revoke', 'c1', 'UNSPECIFIED']])
})
test('clears the SES mailbox through the email adapter', async () => {
let cleared = false
const app = appWithRoutes([mockAdapter('aws', {
@@ -1403,6 +1432,18 @@ describe('service descriptors', () => {
expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'})
expect(secretsFor(gcp)).toMatchObject({availability: 'available', route: 'secrets'})
})
test('marks a service listable only when an adapter serves the generic list', async () => {
// MSK is available through its own pre-SPI page; Console Home must not count it
// through /resources, which has no adapter behind it and answers 501.
const body = await (await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/aws/services')).json() as Array<{service: string; availability: string; listable: boolean}>
const of = (service: string) => body.find((descriptor) => descriptor.service === service)
expect(of('storage')).toMatchObject({availability: 'available', listable: true})
expect(of('msk')).toMatchObject({availability: 'available', listable: false})
expect(of('msk-connect')).toMatchObject({availability: 'available', listable: false})
expect(of('serverless')).toMatchObject({availability: 'coming_soon', listable: false})
})
})
describe('AppConfig nested routes', () => {
+28
View File
@@ -2,6 +2,7 @@ import {Hono} from 'hono'
import type {Context} from 'hono'
import type {
AlarmStateValue,
CertificateRevocationReason,
CloudProvider,
CloudServiceType,
CreateDatabaseSnapshotInput,
@@ -525,6 +526,33 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
app.get('/:cloud/services/certificates/resources/:id/pem', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).getCertificatePem(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/certificates/resources/:id/export', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{passphrase?: string}>()
return c.json(await svc(c).exportCertificate(cloud, c.req.param('id'), body.passphrase as string))
})
})
app.post('/:cloud/services/certificates/resources/:id/revoke', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{reason?: CertificateRevocationReason}>()
return c.json(await svc(c).revokeCertificate(cloud, c.req.param('id'), body.reason ?? 'UNSPECIFIED'))
})
})
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -93,6 +93,9 @@ import type {
UserPoolSignInTestResult,
UserPoolClientSettings,
DistributionInvalidation,
CertificatePem,
CertificateRevocationReason,
ExportedCertificate,
AlarmHistoryItem,
AlarmStateValue,
MetricSeries,
@@ -177,6 +180,7 @@ export class CloudProxyService {
iconKey: entry.iconKey,
group: entry.group,
order: entry.order,
listable: Boolean(adapter),
}
})
}
@@ -941,6 +945,24 @@ export class CloudProxyService {
return adapter.createDistributionInvalidation(distributionId, paths)
}
async getCertificatePem(cloud: CloudProvider, certificateId: string): Promise<CertificatePem> {
const adapter = this.requireAdapter(cloud, 'certificates')
if (!adapter.getCertificatePem) throw new NotSupportedError(`Downloading certificates is not supported for ${cloud}/certificates`)
return adapter.getCertificatePem(certificateId)
}
async exportCertificate(cloud: CloudProvider, certificateId: string, passphrase: string): Promise<ExportedCertificate> {
const adapter = this.requireAdapter(cloud, 'certificates')
if (!adapter.exportCertificate) throw new NotSupportedError(`Exporting certificates is not supported for ${cloud}/certificates`)
return adapter.exportCertificate(certificateId, passphrase)
}
async revokeCertificate(cloud: CloudProvider, certificateId: string, reason: CertificateRevocationReason): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'certificates')
if (!adapter.revokeCertificate) throw new NotSupportedError(`Revoking certificates is not supported for ${cloud}/certificates`)
return adapter.revokeCertificate(certificateId, reason)
}
async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise<UserPoolClientSettings> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
@@ -6,7 +6,7 @@ const STARTED_AT = new Date('2026-10-23T08:00:00.000Z')
const NOW = new Date('2026-10-23T10:30:15.000Z')
function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor {
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1}
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1, listable: availability === 'available'}
}
function proxy(status: Partial<CloudStatus> = {}, descriptors: CloudServiceDescriptor[] = []) {
+26
View File
@@ -150,6 +150,11 @@ export const apiEndpointKeys = {
state: "clouds.services.monitoring.state",
actions: "clouds.services.monitoring.actions",
},
certificates: {
pem: "clouds.services.certificates.pem",
export: "clouds.services.certificates.export",
revoke: "clouds.services.certificates.revoke",
},
cdn: {
enabled: "clouds.services.cdn.enabled",
invalidations: "clouds.services.cdn.invalidations",
@@ -926,6 +931,27 @@ export const endpointRegistry: EndpointRegistry = new Map([
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
[apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}],
[apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}],
[
apiEndpointKeys.clouds.certificates.pem,
{
path: "/clouds/:cloud/services/certificates/resources/:id/pem",
method: "GET",
},
],
[
apiEndpointKeys.clouds.certificates.export,
{
path: "/clouds/:cloud/services/certificates/resources/:id/export",
method: "POST",
},
],
[
apiEndpointKeys.clouds.certificates.revoke,
{
path: "/clouds/:cloud/services/certificates/resources/:id/revoke",
method: "POST",
},
],
[
apiEndpointKeys.clouds.cdn.enabled,
{
@@ -33,6 +33,9 @@ import type {
UserPoolSignInTestResult,
UserPoolClientSettings,
DistributionInvalidation,
CertificatePem,
CertificateRevocationReason,
ExportedCertificate,
AlarmHistoryItem,
AlarmStateValue,
MetricSeries,
@@ -1679,6 +1682,44 @@ export async function testWebSocketApi(cloud: CloudProvider, apiId: string, inpu
return res.data;
}
export async function getCertificatePem(
cloud: CloudProvider,
certificateId: string,
): Promise<CertificatePem> {
const res = await apiClient.call<CertificatePem>(
apiEndpointKeys.clouds.certificates.pem,
requestOptions(cloud, "certificates"),
{ cloud, id: certificateId },
);
return res.data;
}
export async function exportCertificate(
cloud: CloudProvider,
certificateId: string,
passphrase: string,
): Promise<ExportedCertificate> {
const res = await apiClient.call<ExportedCertificate, { passphrase: string }>(
apiEndpointKeys.clouds.certificates.export,
requestOptions(cloud, "certificates", { body: { passphrase } }),
{ cloud, id: certificateId },
);
return res.data;
}
export async function revokeCertificate(
cloud: CloudProvider,
certificateId: string,
reason: CertificateRevocationReason,
): Promise<CloudResource> {
const res = await apiClient.call<CloudResource, { reason: CertificateRevocationReason }>(
apiEndpointKeys.clouds.certificates.revoke,
requestOptions(cloud, "certificates", { body: { reason } }),
{ cloud, id: certificateId },
);
return res.data;
}
export async function setDistributionEnabled(
cloud: CloudProvider,
distributionId: string,
@@ -0,0 +1,88 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {CertificatePanel} from './CertificatePanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
getCertificatePem: vi.fn(),
exportCertificate: vi.fn(),
revokeCertificate: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
function certificate(metadata: Record<string, unknown> = {}, status = 'ISSUED'): CloudResource {
return {
id: ID, name: 'notes.thecloud.localhost', cloud: 'aws', service: 'certificates', type: 'certificate',
region: 'us-east-1', createdAt: null, status,
metadata: {
certificateType: 'Amazon issued', exportable: false, inUseBy: [],
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
...metadata,
},
}
}
function renderPanel(resource: CloudResource) {
api.getCloudResource.mockResolvedValue(resource)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><CertificatePanel resource={resource} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.getCertificatePem.mockResolvedValue({certificate: '-----BEGIN CERTIFICATE-----\nLEAF', certificateChain: '-----BEGIN CERTIFICATE-----\nCA'})
api.exportCertificate.mockResolvedValue({certificate: 'LEAF', certificateChain: null, privateKey: '-----BEGIN ENCRYPTED PRIVATE KEY-----'})
api.revokeCertificate.mockResolvedValue(certificate({exportable: true}, 'REVOKED'))
})
test('shows the validation records and that nothing uses the certificate', async () => {
renderPanel(certificate())
expect(await screen.findByText('_a1.notes.thecloud.localhost.')).toBeInTheDocument()
expect(screen.getByText('_e8.acm-validations.aws.')).toBeInTheDocument()
expect(screen.getByText(/Nothing uses this certificate/)).toBeInTheDocument()
// Export and revoke exist only for certificates requested as exportable, as on AWS.
expect(screen.queryByRole('form', {name: 'Export certificate'})).not.toBeInTheDocument()
expect(screen.queryByRole('form', {name: 'Revoke certificate'})).not.toBeInTheDocument()
})
test('lists what uses the certificate, since ACM will not delete it then', async () => {
renderPanel(certificate({inUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1']}))
expect(await screen.findByText('arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1')).toBeInTheDocument()
expect(screen.getByText(/will not delete the certificate/)).toBeInTheDocument()
})
test('gets the certificate and chain as downloadable PEM files', async () => {
const user = userEvent.setup()
renderPanel(certificate())
await user.click(await screen.findByRole('button', {name: 'Get certificate'}))
expect(await screen.findByRole('textbox', {name: 'Certificate'})).toHaveValue('-----BEGIN CERTIFICATE-----\nLEAF')
expect(screen.getByRole('textbox', {name: 'Certificate chain'})).toHaveValue('-----BEGIN CERTIFICATE-----\nCA')
expect(screen.getByRole('link', {name: /notes.thecloud.localhost.pem/})).toHaveAttribute('download', 'notes.thecloud.localhost.pem')
})
test('exports an exportable certificate with the passphrase, then revokes it', async () => {
const user = userEvent.setup()
vi.spyOn(window, 'confirm').mockReturnValue(true)
renderPanel(certificate({exportable: true}))
const exportForm = await screen.findByRole('form', {name: 'Export certificate'})
await user.type(within(exportForm).getByLabelText(/Passphrase/), 'open-sesame')
await user.click(within(exportForm).getByRole('button', {name: 'Export'}))
expect(api.exportCertificate).toHaveBeenCalledWith('aws', ID, 'open-sesame')
expect(await screen.findByRole('textbox', {name: 'Private key (encrypted)'})).toHaveValue('-----BEGIN ENCRYPTED PRIVATE KEY-----')
const revokeForm = screen.getByRole('form', {name: 'Revoke certificate'})
await user.selectOptions(within(revokeForm).getByLabelText('Reason'), 'SUPERSEDED')
await user.click(within(revokeForm).getByRole('button', {name: 'Revoke'}))
expect(api.revokeCertificate).toHaveBeenCalledWith('aws', ID, 'SUPERSEDED')
expect(await screen.findByText(/Certificate revoked/)).toBeInTheDocument()
})
@@ -0,0 +1,166 @@
import {Download, RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {exportCertificate, getCertificatePem, getCloudResource, revokeCertificate} from '@/api/cloudProxyClient'
import {CopyValueButton} from '@/components/CopyValueButton'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CertificatePem, CertificateRevocationReason, CloudResource, ExportedCertificate} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type DomainValidation = {domainName: string; status: string | null; method: string | null; record: {name: string; type: string; value: string} | null}
const REVOCATION_REASONS: Array<{value: CertificateRevocationReason; label: string}> = [
{value: 'UNSPECIFIED', label: 'Unspecified'},
{value: 'KEY_COMPROMISE', label: 'Key compromise'},
{value: 'SUPERSEDED', label: 'Superseded'},
{value: 'AFFILIATION_CHANGED', label: 'Affiliation changed'},
{value: 'CESSATION_OF_OPERATION', label: 'Cessation of operation'},
{value: 'PRIVILEGE_WITHDRAWN', label: 'Privilege withdrawn'},
{value: 'CA_COMPROMISE', label: 'CA compromise'},
]
export function CertificatePanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [passphrase, setPassphrase] = useState('')
const [reason, setReason] = useState<CertificateRevocationReason>('UNSPECIFIED')
const [pem, setPem] = useState<CertificatePem | ExportedCertificate | null>(null)
const [notice, setNotice] = useState('')
const certificateKey = ['acm-certificate', accountId, resource?.id]
// The table row is a ListCertificates summary; validation records and users come from DescribeCertificate.
const certificateQuery = useQuery({
queryKey: certificateKey,
queryFn: ({signal}) => getCloudResource('aws', 'certificates', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setPassphrase('')
setReason('UNSPECIFIED')
setPem(null)
setNotice('')
}, [resource?.id])
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: certificateKey}),
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'certificates']}),
])
const pemMutation = useMutation({
mutationFn: () => getCertificatePem('aws', resource?.id ?? ''),
onSuccess: setPem,
})
const exportMutation = useMutation({
mutationFn: () => exportCertificate('aws', resource?.id ?? '', passphrase),
onSuccess: (exported) => {
setPem(exported)
setPassphrase('')
setNotice('Exported. The private key is encrypted with your passphrase.')
},
})
const revokeMutation = useMutation({
mutationFn: () => revokeCertificate('aws', resource?.id ?? '', reason),
onSuccess: () => {
setNotice('Certificate revoked. It can no longer be renewed or exported.')
return refresh()
},
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a certificate</h3><p>Choose a certificate to see its validation records, where it is used, and to download or export it.</p></div></section>
}
const certificate = certificateQuery.data ?? resource
const metadata = certificate.metadata
const validations = list<DomainValidation>(metadata.domainValidations)
const inUseBy = list<string>(metadata.inUseBy)
const exportable = metadata.exportable === true
const issued = certificate.status === 'ISSUED'
const pending = pemMutation.isPending || exportMutation.isPending || revokeMutation.isPending
const disabled = !runtimeReachable || pending
const error = certificateQuery.error ?? pemMutation.error ?? exportMutation.error ?? revokeMutation.error
const fileStem = certificate.name.replace(/^\*\./, 'wildcard.').replace(/[^a-z0-9.-]/gi, '_')
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Certificate ${certificate.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Certificate</p><span className="muted">{certificate.status ?? 'Unknown'} · {String(metadata.certificateType ?? '')}{exportable ? ' · exportable' : ''}{metadata.notAfter ? ` · expires ${formatDateTime(String(metadata.notAfter))}` : ''}</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); pemMutation.mutate() }}><Download size={14}/>{pemMutation.isPending ? 'Loading' : 'Get certificate'}</button>
<button className="button" type="button" disabled={!runtimeReachable || certificateQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{certificateQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
{validations.length > 0 && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Domains</p><span className="muted">On AWS, each name is validated by the CNAME record below before the certificate is issued. TheCloud issues it straight away.</span></div></div>
<div className="cognito-table"><table className="table"><thead><tr><th>Domain</th><th>Validation</th><th>CNAME name</th><th>CNAME value</th></tr></thead><tbody>{validations.map((validation) => (
<tr key={validation.domainName}>
<td className="mono">{validation.domainName}</td>
<td><code>{validation.status ?? '—'}</code>{validation.method ? ` ${validation.method}` : ''}</td>
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.name}<CopyValueButton value={validation.record.name} label="CNAME name"/></span> : '—'}</td>
<td className="mono dns-values">{validation.record ? <span className="copy-value">{validation.record.value}<CopyValueButton value={validation.record.value} label="CNAME value"/></span> : '—'}</td>
</tr>
))}</tbody></table></div>
</>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">In use by</p><span className="muted">{inUseBy.length > 0 ? 'ACM will not delete the certificate until these stop using it.' : 'Nothing uses this certificate, so it can be deleted.'}</span></div></div>
{inUseBy.length > 0 && <ul className="certificate-users">{inUseBy.map((arn) => <li key={arn} className="mono">{arn}</li>)}</ul>}
{exportable && issued && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Export</p><span className="muted">Download the certificate with its private key, encrypted with a passphrase, to use on a server outside AWS.</span></div></div>
<form className="dynamic-form" aria-label="Export certificate" onSubmit={(event) => { event.preventDefault(); setNotice(''); exportMutation.mutate() }}>
<label className="dynamic-field"><span>Passphrase</span>
<input className="input" type="password" autoComplete="new-password" value={passphrase} onChange={(event) => setPassphrase(event.target.value)} disabled={disabled} minLength={4} maxLength={128} required/>
<small className="muted">4 to 128 characters, without #, $ or %. You need it to decrypt the key.</small>
</label>
<div><button className="button primary" type="submit" disabled={disabled || passphrase.length < 4}>{exportMutation.isPending ? 'Exporting' : 'Export'}</button></div>
</form>
</>}
{exportable && certificate.status !== 'REVOKED' && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Revoke</p><span className="muted">Revoking cannot be undone. Use it if the private key leaked or the certificate is no longer needed.</span></div></div>
<form className="dynamic-form" aria-label="Revoke certificate" onSubmit={(event) => {
event.preventDefault()
setNotice('')
if (window.confirm(`Revoke the certificate for ${certificate.name}? This cannot be undone.`)) revokeMutation.mutate()
}}>
<label className="dynamic-field"><span>Reason</span>
<select className="input" value={reason} onChange={(event) => setReason(event.target.value as CertificateRevocationReason)} disabled={disabled}>
{REVOCATION_REASONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div><button className="button danger" type="submit" disabled={disabled}>{revokeMutation.isPending ? 'Revoking' : 'Revoke'}</button></div>
</form>
</>}
{pem && <div className="certificate-pem">
<PemBlock label="Certificate" value={pem.certificate} file={`${fileStem}.pem`}/>
{pem.certificateChain && <PemBlock label="Certificate chain" value={pem.certificateChain} file={`${fileStem}-chain.pem`}/>}
{'privateKey' in pem && <PemBlock label="Private key (encrypted)" value={pem.privateKey} file={`${fileStem}-key.pem`}/>}
</div>}
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
function PemBlock({label, value, file}: {label: string; value: string; file: string}) {
const href = `data:application/x-pem-file;charset=utf-8,${encodeURIComponent(value)}`
return <div className="certificate-pem-block">
<div className="certificate-pem-head">
<strong>{label}</strong>
<CopyValueButton value={value} label={label}/>
<a className="button" href={href} download={file}><Download size={13}/> {file}</a>
</div>
<textarea className="textarea code-textarea small" rows={6} value={value} readOnly aria-label={label}/>
</div>
}
function list<T>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : []
}
@@ -68,6 +68,7 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
import { CertificatePanel } from "@/components/CertificatePanel";
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
@@ -987,6 +988,9 @@ export function DynamicResourceView({
{service === "dns" && cloud === "aws" && (
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "certificates" && cloud === "aws" && (
<CertificatePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "cdn" && cloud === "aws" && (
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
@@ -6,6 +6,7 @@ import {
Container,
Database,
Earth,
FileBadge,
Globe,
HardDrive,
Hammer,
@@ -36,6 +37,7 @@ import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
import certificateManagerIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Certificate-Manager.svg'
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
@@ -95,6 +97,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
userpools: UsersRound,
dns: Globe,
cdn: Earth,
certificates: FileBadge,
monitoring: Activity,
loadbalancing: Scale,
parameters: SlidersHorizontal,
@@ -142,6 +145,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
userpools: cognitoIcon,
dns: route53Icon,
cdn: cloudFrontIcon,
certificates: certificateManagerIcon,
monitoring: cloudWatchIcon,
workflows: stepFunctionsIcon,
}
@@ -0,0 +1,52 @@
import type {ReactNode} from 'react'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {renderHook, waitFor} from '@testing-library/react'
import {beforeEach, expect, test, vi} from 'vitest'
import {useCloudConsoleHomeData} from './useCloudConsoleHomeData'
import type {CloudServiceDescriptor} from '@/types/cloud'
const api = vi.hoisted(() => ({
listClouds: vi.fn(),
listCloudServices: vi.fn(),
getCloudStatus: vi.fn(),
listCloudResources: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
function descriptor(service: CloudServiceDescriptor['service'], route: string, listable: boolean): CloudServiceDescriptor {
return {cloud: 'aws', service, displayName: service, availability: 'available', route, iconKey: service, group: 'Storage', order: 1, listable}
}
function wrapper({children}: {children: ReactNode}) {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}
beforeEach(() => {
vi.clearAllMocks()
api.listClouds.mockResolvedValue([])
api.getCloudStatus.mockResolvedValue({cloud: 'aws', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: '2026-10-25T10:00:00Z', error: null})
api.listCloudServices.mockResolvedValue([
descriptor('storage', 'storage', true),
descriptor('secrets', '/secretsmanager', true),
descriptor('msk', 'msk', false),
])
api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => {
if (service === 'msk') throw new Error('501 operation_not_supported')
return service === 'storage' ? [{id: 'a'}, {id: 'b'}] : [{id: 'c'}]
})
})
test('counts only services the generic list serves, so a pre-SPI page cannot fail the total', async () => {
const {result} = renderHook(() => useCloudConsoleHomeData('aws'), {wrapper})
await waitFor(() => expect(result.current.resourceCount).toBe(3))
expect(api.listCloudResources.mock.calls.map((call) => call[1]).sort()).toEqual(['secrets', 'storage'])
expect(result.current.resourceDetail).toBe('Normalized resources across available services')
expect(result.current.activeServices).toBe(3)
const msk = result.current.serviceCards.find((card) => card.id === 'msk')
expect(msk).toMatchObject({route: '/cloud-explorer/aws/msk', meta: 'open service', count: undefined})
expect(result.current.serviceCards.find((card) => card.id === 'secrets')).toMatchObject({route: '/secretsmanager', count: 1})
})
@@ -32,12 +32,12 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) {
const status = statusQuery.data
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data])
// Availability is derived from adapter registration, so an available service
// always has the generic list endpoint. Route shape only decides where the
// card links, not whether it can be counted -- a legacy absolute-route page
// (AWS Secrets Manager) is still counted through its adapter.
// Only a service with a registered adapter has the generic list endpoint. Route
// shape only decides where the card links: a legacy absolute-route page (AWS
// Secrets Manager) is still counted through its adapter, while MSK, available
// through its own pre-SPI page, is not listable and is never counted.
const countable = useMemo(
() => services.filter((service) => service.availability === 'available'),
() => services.filter((service) => service.availability === 'available' && service.listable),
[services],
)
@@ -85,7 +85,7 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) {
: undefined,
meta:
service.availability === 'available'
? isLegacyPage
? isLegacyPage || !service.listable
? 'open service'
: serviceMetaLabel(status, counts?.isLoading ?? false, 'resources')
: 'coming soon',
+6
View File
@@ -6610,3 +6610,9 @@ tr[aria-current="true"] td { background: var(--hover-md); }
.metric-chart-table { margin-top: 8px; }
.metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; }
.metric-chart-table table { margin-top: 8px; }
/* ACM: the resources using a certificate, and its PEM blocks. */
.certificate-users { margin: 0; padding: 0 16px 12px 32px; display: grid; gap: 4px; overflow-wrap: anywhere; }
.certificate-pem { display: grid; gap: 12px; padding: 0 16px 16px; }
.certificate-pem-block { display: grid; gap: 6px; min-width: 0; }
.certificate-pem-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+5
View File
@@ -57,6 +57,11 @@ export interface CloudServiceDescriptor {
iconKey: string
group: ServiceGroup
order: number
/**
* Whether the generic resource list serves it. False for a service still on its
* own pre-SPI page (MSK, MSK Connect): available, but not countable.
*/
listable: boolean
}
export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
+19
View File
@@ -337,6 +337,25 @@ export interface AlarmHistoryItem {
summary: string
}
export interface CertificatePem {
certificate: string
certificateChain: string | null
}
/** The private key is PEM, encrypted with the passphrase given to export. */
export interface ExportedCertificate extends CertificatePem {
privateKey: string
}
export type CertificateRevocationReason =
| 'UNSPECIFIED'
| 'KEY_COMPROMISE'
| 'CA_COMPROMISE'
| 'AFFILIATION_CHANGED'
| 'SUPERSEDED'
| 'CESSATION_OF_OPERATION'
| 'PRIVILEGE_WITHDRAWN'
export interface DistributionInvalidation {
id: string
status: string
+2
View File
@@ -28,6 +28,7 @@ export type KubernetesActionName =
| 'deleteFargateProfile'
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type CertificateActionName = 'download' | 'export' | 'revoke'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
@@ -91,6 +92,7 @@ export interface ServiceSchema {
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
certificateActions?: Array<CapabilitySchema<CertificateActionName>>
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
}
filters: FieldSchema[]
+18
View File
@@ -10,6 +10,9 @@ importers:
packages/api:
dependencies:
'@aws-sdk/client-acm':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-api-gateway':
specifier: ^3.1076.0
version: 3.1096.0
@@ -298,6 +301,10 @@ packages:
resolution: {integrity: sha512-USI1N1d+NbCcfjeiYLKIl94PSYykl5IIjj4X3WqdL33Ln5qktA6sjXYct0o/gG2Cte7cuzbXKkDBkzUA4VPHrg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-acm@3.1156.0':
resolution: {integrity: sha512-vRy8mVgYOO1E/YXDlBuXCUKsmNKkVjKFIW2ZS30kWZydbS6CnqFxNkMajDzqJcm+PZ74rwgnhNHsJkc+UIEMzQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-api-gateway@3.1096.0':
resolution: {integrity: sha512-RPzDojG3/YTdOMWGQ9mhE51db0IxL8YSjN9fKe5UawwgGrLa915ik71SMYk4z7hywkANuPTqRtt/kNJ863/j0g==}
engines: {node: '>=20.0.0'}
@@ -2896,6 +2903,17 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-acm@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-api-gateway@3.1096.0':
dependencies:
'@aws-sdk/core': 3.977.1