Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5a2fc9105 | |||
| c9b0677fed | |||
| 0371a8884f | |||
| 75432a4bc7 | |||
| c519d41799 | |||
| ba90eff5db | |||
| 502d0b3986 | |||
| 2db02ed369 |
@@ -20,6 +20,10 @@ FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS=1800
|
|||||||
FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS=28800
|
FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS=28800
|
||||||
# Set false only for local HTTP development. Keep true behind HTTPS reverse proxies.
|
# Set false only for local HTTP development. Keep true behind HTTPS reverse proxies.
|
||||||
FLOCI_UI_SECURE_COOKIES=true
|
FLOCI_UI_SECURE_COOKIES=true
|
||||||
|
# Reverse proxies (IPs or CIDRs, comma-separated) allowed to report the client address
|
||||||
|
# via X-Forwarded-For. Required behind Nginx Proxy Manager, otherwise every visitor
|
||||||
|
# shares the proxy's address for sign-in throttling. Leave empty when exposed directly.
|
||||||
|
# FLOCI_UI_TRUSTED_PROXIES=192.168.1.10
|
||||||
|
|
||||||
# Optional Authentik / OIDC mode. These are server-only values.
|
# Optional Authentik / OIDC mode. These are server-only values.
|
||||||
# FLOCI_UI_AUTH_MODE=oidc
|
# FLOCI_UI_AUTH_MODE=oidc
|
||||||
@@ -27,3 +31,8 @@ FLOCI_UI_SECURE_COOKIES=true
|
|||||||
# FLOCI_UI_OIDC_CLIENT_ID=
|
# FLOCI_UI_OIDC_CLIENT_ID=
|
||||||
# FLOCI_UI_OIDC_CLIENT_SECRET=
|
# FLOCI_UI_OIDC_CLIENT_SECRET=
|
||||||
# FLOCI_UI_PUBLIC_BASE_URL=https://thecloud.example.com
|
# FLOCI_UI_PUBLIC_BASE_URL=https://thecloud.example.com
|
||||||
|
# OIDC mode refuses to start without an allow-list (comma-separated). Group names come
|
||||||
|
# from the `groups` claim; emails must also be verified by the identity provider.
|
||||||
|
# FLOCI_UI_OIDC_ALLOWED_GROUPS=thecloud-admins
|
||||||
|
# FLOCI_UI_OIDC_ALLOWED_EMAILS=
|
||||||
|
# FLOCI_UI_OIDC_ALLOW_ALL_USERS=false
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ Open [http://localhost:4500](http://localhost:4500).
|
|||||||
|
|
||||||
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`.
|
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`.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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`.
|
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.
|
||||||
|
|
||||||
## What The UI Actually Exposes Today
|
## What The UI Actually Exposes Today
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ services:
|
|||||||
FLOCI_UI_OIDC_CLIENT_ID: ${FLOCI_UI_OIDC_CLIENT_ID:-}
|
FLOCI_UI_OIDC_CLIENT_ID: ${FLOCI_UI_OIDC_CLIENT_ID:-}
|
||||||
FLOCI_UI_OIDC_CLIENT_SECRET: ${FLOCI_UI_OIDC_CLIENT_SECRET:-}
|
FLOCI_UI_OIDC_CLIENT_SECRET: ${FLOCI_UI_OIDC_CLIENT_SECRET:-}
|
||||||
FLOCI_UI_PUBLIC_BASE_URL: ${FLOCI_UI_PUBLIC_BASE_URL:-}
|
FLOCI_UI_PUBLIC_BASE_URL: ${FLOCI_UI_PUBLIC_BASE_URL:-}
|
||||||
|
FLOCI_UI_OIDC_ALLOWED_GROUPS: ${FLOCI_UI_OIDC_ALLOWED_GROUPS:-}
|
||||||
|
FLOCI_UI_OIDC_ALLOWED_EMAILS: ${FLOCI_UI_OIDC_ALLOWED_EMAILS:-}
|
||||||
|
FLOCI_UI_OIDC_ALLOW_ALL_USERS: ${FLOCI_UI_OIDC_ALLOW_ALL_USERS:-false}
|
||||||
|
FLOCI_UI_TRUSTED_PROXIES: ${FLOCI_UI_TRUSTED_PROXIES:-}
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
+7
-1
@@ -23,13 +23,19 @@ RUN bun build --compile --minify src/index.ts --outfile server
|
|||||||
|
|
||||||
# Stage 3 — minimal runtime (no bun, no node_modules)
|
# Stage 3 — minimal runtime (no bun, no node_modules)
|
||||||
FROM alpine:3
|
FROM alpine:3
|
||||||
RUN apk add --no-cache ca-certificates libstdc++
|
RUN apk add --no-cache ca-certificates libstdc++ \
|
||||||
|
&& addgroup -S -g 10001 thecloud \
|
||||||
|
&& adduser -S -D -H -u 10001 -G thecloud thecloud
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=api-build /app/server ./server
|
COPY --from=api-build /app/server ./server
|
||||||
COPY --from=frontend-build /app/packages/frontend/dist ./public
|
COPY --from=frontend-build /app/packages/frontend/dist ./public
|
||||||
|
COPY packages/api/public ./public
|
||||||
COPY TheCloud.png ./public/TheCloud.png
|
COPY TheCloud.png ./public/TheCloud.png
|
||||||
COPY svgAsset/authentik.svg ./public/authentik.svg
|
COPY svgAsset/authentik.svg ./public/authentik.svg
|
||||||
COPY aws-icons-svg ./public/aws-icons
|
COPY aws-icons-svg ./public/aws-icons
|
||||||
ENV PORT=4500
|
ENV PORT=4500
|
||||||
EXPOSE 4500
|
EXPOSE 4500
|
||||||
|
USER thecloud
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-4500}/api/health" || exit 1
|
||||||
CMD ["./server"]
|
CMD ["./server"]
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en" data-theme="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Sign in · TheCloud</title>
|
|
||||||
<style>
|
|
||||||
:root[data-theme=dark]{--frame:#07111d;--rail:#0a1626;--rfg:#e8f1fa;--rmut:#8aa3bc;--rbd:rgba(232,241,250,.14);--ac:#6cc4ff;--on:#06223a;
|
|
||||||
--pbg:#08131f;--grid:rgba(232,241,250,.08);--o1:#6cc4ff;--o2:#ffb547;--pfg:#f2f8ff;--pmut:#a9c3dc;--tile:rgba(12,26,43,.72);--tbd:rgba(232,241,250,.14);--ic:#8fd3ff;--hi:#ffb547;--glow:rgba(108,196,255,.16);--err:#ff9a9a;--errbg:#3a1822}
|
|
||||||
:root[data-theme=light]{--frame:#eef4fa;--rail:#0e2238;--rfg:#f2f8ff;--rmut:#9db6cf;--rbd:rgba(242,248,255,.18);--ac:#7fd0ff;--on:#06223a;
|
|
||||||
--pbg:#eaf2fa;--grid:rgba(14,34,56,.09);--o1:#0a6fc2;--o2:#c26a00;--pfg:#0e2238;--pmut:#4a627a;--tile:rgba(255,255,255,.8);--tbd:rgba(14,34,56,.14);--ic:#0a6fc2;--hi:#c26a00;--glow:rgba(10,111,194,.14);--err:#ffb0b0;--errbg:#3a1822}
|
|
||||||
:root[data-theme=cyber]{--frame:#010305;--rail:#02080a;--rfg:#4eff96;--rmut:#2f9a68;--rbd:rgba(78,255,150,.3);--ac:#ffea00;--on:#02080a;
|
|
||||||
--pbg:#02060a;--grid:rgba(78,255,150,.16);--o1:#4eff96;--o2:#ff10f0;--pfg:#4eff96;--pmut:#2fb877;--tile:rgba(5,13,16,.8);--tbd:rgba(78,255,150,.34);--ic:#4eff96;--hi:#ff10f0;--glow:rgba(78,255,150,.18);--err:#ff8da1;--errbg:#2a0a14}
|
|
||||||
*{box-sizing:border-box;margin:0}
|
|
||||||
html,body{min-height:100%}
|
|
||||||
body{background:var(--frame);color:var(--rfg);font-family:system-ui,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
|
|
||||||
body.fade,body.fade *{transition:background-color .5s,color .5s,border-color .5s!important}
|
|
||||||
.mono{font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace}
|
|
||||||
.defs{position:absolute;width:0;height:0;overflow:hidden}
|
|
||||||
.defs .hi{stroke:var(--hi)}.defs .hf{fill:var(--hi);stroke:none}
|
|
||||||
|
|
||||||
.frame{min-height:100vh;display:grid;grid-template-columns:400px minmax(0,1fr)}
|
|
||||||
|
|
||||||
/* rail */
|
|
||||||
.rail{background:var(--rail);padding:44px 48px 32px;display:flex;flex-direction:column;justify-content:space-between;gap:40px}
|
|
||||||
.brand{font-size:34px;font-weight:800;letter-spacing:-.045em;color:var(--ac);line-height:1;text-decoration:none;display:inline-block}
|
|
||||||
.signin{margin-top:clamp(40px,14vh,150px)}
|
|
||||||
.signin h2{font-size:15px;font-weight:600;margin-bottom:6px}
|
|
||||||
.lead{font-size:14px;line-height:1.55;color:var(--rmut)}
|
|
||||||
.dest{display:none;margin-top:16px;padding:9px 12px;border:1px dashed var(--rbd);border-radius:10px;font-size:12.5px;color:var(--rmut);overflow-wrap:anywhere}
|
|
||||||
.dest.on{display:block}.dest span{color:var(--rfg)}
|
|
||||||
.alert{display:none;margin-top:16px;padding:12px 14px;border:1px solid currentColor;border-radius:12px;background:var(--errbg);color:var(--err);font-size:14px;line-height:1.45}
|
|
||||||
.alert.on{display:block}
|
|
||||||
.btn{margin-top:22px;width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:15px 22px;border:0;border-radius:999px;background:var(--ac);color:var(--on);font:700 15px system-ui,sans-serif;text-decoration:none;cursor:pointer;transition:transform .15s,box-shadow .2s,filter .2s}
|
|
||||||
.btn:hover{transform:translateY(-2px);box-shadow:0 12px 28px -10px var(--ac);filter:brightness(1.06)}
|
|
||||||
.btn:active{transform:none}
|
|
||||||
.btn:focus-visible,.tg button:focus-visible{outline:3px solid var(--rfg);outline-offset:3px}
|
|
||||||
.btn[aria-busy=true]{pointer-events:none;filter:saturate(.6)}
|
|
||||||
.sp{display:none;width:15px;height:15px;border:2.5px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}
|
|
||||||
.btn[aria-busy=true] .sp{display:block}.btn[aria-busy=true] .sh{display:none}
|
|
||||||
@keyframes spin{to{transform:rotate(360deg)}}
|
|
||||||
.sso{margin-top:14px;font-size:12.5px;color:var(--rmut);text-align:center}
|
|
||||||
.foot{display:grid;gap:16px}
|
|
||||||
.tg{display:inline-flex;width:max-content;gap:2px;padding:3px;border:1px solid var(--rbd);border-radius:999px}
|
|
||||||
.tg button{width:30px;height:30px;border:0;border-radius:999px;background:none;color:var(--rmut);cursor:pointer;display:grid;place-items:center;transition:.25s}
|
|
||||||
.tg button:hover{color:var(--rfg)}
|
|
||||||
.tg button.on{background:var(--ac);color:var(--on)}
|
|
||||||
.legal{font-size:11.5px;line-height:1.55;color:var(--rmut)}
|
|
||||||
|
|
||||||
/* panel */
|
|
||||||
.dot{position:absolute;border-radius:50%;filter:blur(70px);opacity:.24;animation:dr 16s ease-in-out infinite;pointer-events:none}
|
|
||||||
.d1{width:460px;height:460px;top:-8%;left:-120px;background:var(--o1)}
|
|
||||||
.d2{width:540px;height:540px;bottom:-22%;right:-160px;background:var(--o2);animation-delay:-5s}
|
|
||||||
@keyframes dr{33%{transform:translate(60px,-40px) scale(1.1)}66%{transform:translate(-30px,50px) scale(.95)}}
|
|
||||||
.panel{position:relative;margin:20px;border-radius:28px;overflow:hidden;display:flex;align-items:center;justify-content:center;padding:56px 40px;
|
|
||||||
background:linear-gradient(var(--grid) 1px,transparent 1px) center/56px 56px,linear-gradient(90deg,var(--grid) 1px,transparent 1px) center/56px 56px,var(--pbg);color:var(--pfg);border:1px solid var(--tbd)}
|
|
||||||
.pglow{position:absolute;width:640px;height:640px;border-radius:50%;background:radial-gradient(circle,var(--glow),transparent 62%);transform:translate(-50%,-50%);pointer-events:none;opacity:0;transition:opacity .4s}
|
|
||||||
.inner{position:relative;width:100%;max-width:960px;text-align:center}
|
|
||||||
h1{font-family:ui-serif,"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;font-weight:500;font-size:clamp(2.2rem,4.6vw,4rem);line-height:1.08;letter-spacing:-.02em;max-width:15em;margin:0 auto 48px;text-wrap:balance}
|
|
||||||
h1::after{content:"";display:inline-block;width:.08em;height:.85em;margin-left:.12em;background:var(--hi);vertical-align:-.05em;animation:bl 1.1s steps(1) infinite}
|
|
||||||
@keyframes bl{50%{opacity:0}}
|
|
||||||
.tiles{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:14px;list-style:none;padding:0}
|
|
||||||
.tile{padding:22px 8px 18px;border-radius:18px;background:var(--tile);border:1px solid var(--tbd);display:grid;align-content:start;justify-items:center;gap:12px;opacity:0;animation:up .6s cubic-bezier(.2,.7,.2,1) forwards;transition:transform .25s,border-color .25s,box-shadow .25s}
|
|
||||||
.tile:hover{transform:translateY(-5px);border-color:var(--hi);box-shadow:0 18px 36px -18px rgba(0,0,0,.5)}
|
|
||||||
.tile:nth-child(2){animation-delay:.06s}.tile:nth-child(3){animation-delay:.12s}.tile:nth-child(4){animation-delay:.18s}.tile:nth-child(5){animation-delay:.24s}.tile:nth-child(6){animation-delay:.3s}
|
|
||||||
@keyframes up{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
|
|
||||||
.ic{width:52px;height:52px;color:var(--ic)}
|
|
||||||
.tile b{display:block;font-size:14px;font-weight:650;line-height:1.25}
|
|
||||||
.tile small{display:block;margin-top:3px;font-size:11.5px;color:var(--pmut)}
|
|
||||||
.tag{margin-top:40px;font-size:16px;color:var(--pmut)}
|
|
||||||
|
|
||||||
@media (max-width:980px){.tiles{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
|
||||||
@media (max-width:900px){
|
|
||||||
.frame{grid-template-columns:1fr}
|
|
||||||
.rail{padding:28px 24px 24px;gap:26px}.signin{margin-top:22px}
|
|
||||||
.foot{order:3}
|
|
||||||
.panel{margin:0 14px 14px;padding:40px 18px;border-radius:22px}
|
|
||||||
h1{margin-bottom:30px}.tag{display:none}
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}.tile{opacity:1}.pglow{display:none}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<svg class="defs" aria-hidden="true" focusable="false">
|
|
||||||
<symbol id="i-lambda" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 9h7l15 30"/><path class="hi" d="M24 25l-10 14"/></g></symbol>
|
|
||||||
<symbol id="i-s3" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="24" cy="12" rx="15" ry="5.5"/><path d="M9 12l3.4 25c.4 2.8 5.700 5 11.600 5s11.200-2.200 11.600-5L39 12"/><path class="hi" d="M11 23c3 2.600 8.200 3.800 13 3.800S34 25.600 37 23"/></g></symbol>
|
|
||||||
<symbol id="i-sqs" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="9" width="34" height="8" rx="4"/><rect x="7" y="20" width="34" height="8" rx="4"/><rect x="7" y="31" width="34" height="8" rx="4"/><path class="hi" d="M13 24h8m-3-3l3 3-3 3"/></g></symbol>
|
|
||||||
<symbol id="i-sns" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M13 13a16 16 0 0 0 0 22M35 13a16 16 0 0 1 0 22M19 18a8 8 0 0 0 0 12M29 18a8 8 0 0 1 0 12"/><circle class="hi hf" cx="24" cy="24" r="3.6"/></g></symbol>
|
|
||||||
<symbol id="i-ddb" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="24" cy="11" rx="14" ry="5.500"/><path d="M10 11v26c0 3 6.300 5.500 14 5.500S38 40 38 37V11"/><path class="hi" d="M10 24c0 3 6.300 5.500 14 5.500S38 27 38 24"/></g></symbol>
|
|
||||||
<symbol id="i-sfn" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 24h6c4 0 5-9 9-9h2M13 24h6c4 0 5 9 9 9h2"/><rect x="30" y="9" width="11" height="11" rx="3"/><rect x="30" y="28" width="11" height="11" rx="3"/><circle class="hi" cx="9" cy="24" r="4"/></g></symbol>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="frame">
|
|
||||||
<aside class="rail">
|
|
||||||
<div>
|
|
||||||
<a class="brand" href="/">TheCloud</a>
|
|
||||||
<div class="signin">
|
|
||||||
<h2>Welcome back</h2>
|
|
||||||
<p class="lead">Continue with your Authentik account to open the console.</p>
|
|
||||||
<div class="dest mono" id="dest">Landing on <span id="destPath"></span></div>
|
|
||||||
<div class="alert" id="alert" role="alert"></div>
|
|
||||||
<a class="btn" id="go" href="/api/auth/oidc/login">
|
|
||||||
<svg class="sh" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 3v6c0 4.500-3.200 8-8 9-4.800-1-8-4.500-8-9V6l8-3z"/><path d="M9 12l2 2 4-4"/></svg>
|
|
||||||
<span class="sp" aria-hidden="true"></span><span id="label">Sign in with Authentik</span>
|
|
||||||
</a>
|
|
||||||
<p class="sso">Single sign-on. MFA happens there.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="foot">
|
|
||||||
<div class="tg" role="group" aria-label="Theme">
|
|
||||||
<button data-t="dark" aria-label="Dark theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M21 13.500A9 9 0 1 1 10.500 3a7 7 0 0 0 10.500 10.500z"/></svg></button>
|
|
||||||
<button data-t="light" aria-label="Light theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.900 4.900l1.400 1.400M17.700 17.700l1.400 1.400M4.900 19.100l1.400-1.400M17.700 6.300l1.400-1.400"/></svg></button>
|
|
||||||
<button data-t="cyber" aria-label="Cyber theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.400" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l6 6-6 6M12 19h8"/></svg></button>
|
|
||||||
</div>
|
|
||||||
<p class="legal">TheCloud is powered by Floci and is not affiliated with Amazon Web Services.</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="panel" id="panel">
|
|
||||||
<div class="dot d1"></div><div class="dot d2"></div>
|
|
||||||
<div class="pglow" id="pglow"></div>
|
|
||||||
|
|
||||||
<div class="inner">
|
|
||||||
<h1>Your own cloud, on your own hardware.</h1>
|
|
||||||
<ul class="tiles">
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-lambda"/></svg><span><b>Invoke functions</b><small>Lambda</small></span></li>
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-s3"/></svg><span><b>Store objects</b><small>S3</small></span></li>
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sqs"/></svg><span><b>Queue messages</b><small>SQS</small></span></li>
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sns"/></svg><span><b>Publish events</b><small>SNS</small></span></li>
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-ddb"/></svg><span><b>Query tables</b><small>DynamoDB</small></span></li>
|
|
||||||
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sfn"/></svg><span><b>Orchestrate workflows</b><small>Step Functions</small></span></li>
|
|
||||||
</ul>
|
|
||||||
<p class="tag">Build and test against AWS services locally, with SSO in front.</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
var root=document.documentElement,btns=document.querySelectorAll('.tg button');
|
|
||||||
function setTheme(t,anim){
|
|
||||||
if(anim){document.body.classList.add('fade');setTimeout(function(){document.body.classList.remove('fade')},600)}
|
|
||||||
root.setAttribute('data-theme',t);
|
|
||||||
try{localStorage.setItem('thecloud-theme',t)}catch(e){}
|
|
||||||
btns.forEach(function(b){b.classList.toggle('on',b.dataset.t===t)});
|
|
||||||
}
|
|
||||||
var t0=matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';
|
|
||||||
try{var sv=localStorage.getItem('thecloud-theme');if(sv==='dark'||sv==='light'||sv==='cyber')t0=sv}catch(e){}
|
|
||||||
setTheme(t0,false);
|
|
||||||
btns.forEach(function(b){b.addEventListener('click',function(){setTheme(b.dataset.t,true)})});
|
|
||||||
document.addEventListener('keydown',function(e){
|
|
||||||
if((e.key==='t'||e.key==='T')&&!e.metaKey&&!e.ctrlKey&&!e.altKey){
|
|
||||||
var o=['dark','light','cyber'];setTheme(o[(o.indexOf(root.getAttribute('data-theme'))+1)%3],true)}
|
|
||||||
});
|
|
||||||
|
|
||||||
var panel=document.getElementById('panel'),gl=document.getElementById('pglow');
|
|
||||||
panel.addEventListener('mousemove',function(e){var r=panel.getBoundingClientRect();gl.style.left=(e.clientX-r.left)+'px';gl.style.top=(e.clientY-r.top)+'px';gl.style.opacity='1'});
|
|
||||||
panel.addEventListener('mouseleave',function(){gl.style.opacity='0'});
|
|
||||||
|
|
||||||
var p=new URLSearchParams(location.search),go=document.getElementById('go'),label=document.getElementById('label');
|
|
||||||
var rt=p.get('returnTo')||'/';
|
|
||||||
if(!(rt.length<512&&rt.charAt(0)==='/'&&rt.charAt(1)!=='/'&&rt.charAt(1)!=='\\'&&!/[\u0000-\u001f]/.test(rt)))rt='/';
|
|
||||||
go.href='/api/auth/oidc/login?returnTo='+encodeURIComponent(rt);
|
|
||||||
if(rt!=='/'){document.getElementById('destPath').textContent=rt;document.getElementById('dest').className='dest mono on'}
|
|
||||||
var msgs={access_denied:'Your account is not allowed to use this console. Ask the admin to grant access in Authentik.',session_expired:'Your session ended. Sign in again to continue.',state_mismatch:'The sign-in request expired. Start again.',invalid_state:'The sign-in request expired. Start again.',oidc_failed:'Authentik could not complete the sign-in. Try again in a moment.'};
|
|
||||||
var er=p.get('error');
|
|
||||||
if(er){var a=document.getElementById('alert');a.textContent=Object.prototype.hasOwnProperty.call(msgs,er)?msgs[er]:'Sign-in did not complete. Try again.';a.className='alert on'}
|
|
||||||
go.addEventListener('click',function(ev){
|
|
||||||
if(go.getAttribute('aria-busy')==='true'){ev.preventDefault();return}
|
|
||||||
go.setAttribute('aria-busy','true');label.textContent='Redirecting to Authentik';
|
|
||||||
});
|
|
||||||
addEventListener('pageshow',function(){go.removeAttribute('aria-busy');label.textContent='Sign in with Authentik'});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const buttons = document.querySelectorAll('.tg button');
|
||||||
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
root.setAttribute('data-theme', theme);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('thecloud-theme', theme);
|
||||||
|
} catch (ignored) {
|
||||||
|
// Theme persistence is optional when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
buttons.forEach((button) => button.classList.toggle('on', button.dataset.t === theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
|
try {
|
||||||
|
const savedTheme = localStorage.getItem('thecloud-theme');
|
||||||
|
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
|
||||||
|
initialTheme = savedTheme;
|
||||||
|
}
|
||||||
|
} catch (ignored) {
|
||||||
|
// The OS preference remains the fallback when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
setTheme(initialTheme);
|
||||||
|
|
||||||
|
buttons.forEach((button) => button.addEventListener('click', () => setTheme(button.dataset.t)));
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey && !event.altKey && !/INPUT|TEXTAREA/.test(event.target.tagName)) {
|
||||||
|
const themes = ['dark', 'light', 'cyber'];
|
||||||
|
setTheme(themes[(themes.indexOf(root.getAttribute('data-theme')) + 1) % themes.length]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = ['hello, engineer.', 'aws s3 mb s3://my-bucket', 'your own AWS, on your own hardware.', 'sfn start-execution --local', 'welcome to TheCloud.'];
|
||||||
|
const typewriter = document.getElementById('tw');
|
||||||
|
let line = 0;
|
||||||
|
let count = 0;
|
||||||
|
let deleting = false;
|
||||||
|
if (reducedMotion) {
|
||||||
|
typewriter.textContent = lines[2];
|
||||||
|
} else {
|
||||||
|
function type() {
|
||||||
|
const word = lines[line];
|
||||||
|
if (deleting) {
|
||||||
|
count--;
|
||||||
|
typewriter.textContent = word.slice(0, count);
|
||||||
|
if (count === 0) {
|
||||||
|
deleting = false;
|
||||||
|
line = (line + 1) % lines.length;
|
||||||
|
setTimeout(type, 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(type, 35);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
typewriter.textContent = word.slice(0, count);
|
||||||
|
if (count === word.length) {
|
||||||
|
deleting = true;
|
||||||
|
setTimeout(type, 2200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(type, 75 + Math.random() * 50);
|
||||||
|
}
|
||||||
|
type();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hero = document.getElementById('top');
|
||||||
|
const glow = document.getElementById('grid-glow');
|
||||||
|
if (!reducedMotion) {
|
||||||
|
hero.addEventListener('mousemove', (event) => {
|
||||||
|
const rect = hero.getBoundingClientRect();
|
||||||
|
glow.style.left = `${event.clientX - rect.left}px`;
|
||||||
|
glow.style.top = `${event.clientY - rect.top}px`;
|
||||||
|
glow.style.opacity = '1';
|
||||||
|
});
|
||||||
|
hero.addEventListener('mouseleave', () => {
|
||||||
|
glow.style.opacity = '0';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = document.getElementById('bar');
|
||||||
|
addEventListener('scroll', () => {
|
||||||
|
const height = document.documentElement.scrollHeight - innerHeight;
|
||||||
|
progress.style.width = `${height > 0 ? scrollY / height * 100 : 0}%`;
|
||||||
|
}, {passive: true});
|
||||||
|
|
||||||
|
const form = document.querySelector('[data-sso-form]');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', () => {
|
||||||
|
const button = form.querySelector('.btn');
|
||||||
|
button.classList.add('is-loading');
|
||||||
|
button.disabled = true;
|
||||||
|
document.getElementById('label').textContent = 'Redirecting...';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = document.getElementById('cp');
|
||||||
|
if (copy) {
|
||||||
|
copy.addEventListener('click', () => {
|
||||||
|
navigator.clipboard.writeText(document.getElementById('code').innerText).then(() => {
|
||||||
|
copy.textContent = 'copied';
|
||||||
|
copy.classList.add('ok');
|
||||||
|
setTimeout(() => {
|
||||||
|
copy.textContent = 'copy';
|
||||||
|
copy.classList.remove('ok');
|
||||||
|
}, 1800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import {afterEach, beforeAll, describe, expect, test} from 'bun:test'
|
||||||
|
import {Hono} from 'hono'
|
||||||
|
import {exportJWK, generateKeyPair, SignJWT, type JWK, type JWTPayload} from 'jose'
|
||||||
|
import {authConfig, authMiddleware, authRoutes, authStateSizes, sweepExpired, type AuthConfig} from './auth'
|
||||||
|
|
||||||
|
const SECRET = '01234567890123456789012345678901'
|
||||||
|
const ISSUER = 'https://idp.test/application/o/the-cloud/'
|
||||||
|
const CLIENT_ID = 'the-cloud'
|
||||||
|
const PUBLIC_BASE_URL = 'https://thecloud.test'
|
||||||
|
const NPM_PROXY = '192.168.1.10'
|
||||||
|
const realFetch = globalThis.fetch
|
||||||
|
|
||||||
|
let passwordHash: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
passwordHash = await Bun.password.hash('correct-horse-battery')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = realFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
function app(config: AuthConfig) {
|
||||||
|
const hono = new Hono()
|
||||||
|
hono.use('*', authMiddleware(config))
|
||||||
|
hono.use('*', authRoutes(config))
|
||||||
|
hono.get('/api/protected', (c) => c.json({ok: true}))
|
||||||
|
return hono
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(hono: Hono, url: string, peer: string, init: RequestInit = {}) {
|
||||||
|
return hono.fetch(new Request(`http://localhost${url}`, init), {requestIP: () => ({address: peer, family: peer.includes(':') ? 'IPv6' : 'IPv4', port: 50000})})
|
||||||
|
}
|
||||||
|
|
||||||
|
function localConfig(env: Record<string, string> = {}) {
|
||||||
|
return authConfig({FLOCI_UI_SESSION_SECRET: SECRET, FLOCI_UI_ADMIN_USER: 'admin', FLOCI_UI_ADMIN_PASSWORD_HASH: passwordHash, FLOCI_UI_SECURE_COOKIES: 'false', ...env})
|
||||||
|
}
|
||||||
|
|
||||||
|
function oidcEnv(env: Record<string, string> = {}): Record<string, string> {
|
||||||
|
return {FLOCI_UI_SESSION_SECRET: SECRET, FLOCI_UI_AUTH_MODE: 'oidc', FLOCI_UI_OIDC_ISSUER: ISSUER, FLOCI_UI_OIDC_CLIENT_ID: CLIENT_ID, FLOCI_UI_OIDC_CLIENT_SECRET: 'client-secret', FLOCI_UI_PUBLIC_BASE_URL: PUBLIC_BASE_URL, FLOCI_UI_SECURE_COOKIES: 'false', ...env}
|
||||||
|
}
|
||||||
|
|
||||||
|
function login(hono: Hono, peer: string, password: string, headers: Record<string, string> = {}) {
|
||||||
|
return request(hono, '/api/auth/login', peer, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', ...headers},
|
||||||
|
body: new URLSearchParams({username: 'admin', password, csrf: 'csrf-token'}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function failLogins(hono: Hono, peer: string, count: number, headers: Record<string, string> = {}) {
|
||||||
|
for (let index = 0; index < count; index++) expect((await login(hono, peer, 'wrong-password', headers)).status).toBe(401)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('login throttling', () => {
|
||||||
|
test('locks out the attacking address without locking the admin out elsewhere', async () => {
|
||||||
|
const hono = app(localConfig())
|
||||||
|
await failLogins(hono, '203.0.113.1', 5)
|
||||||
|
|
||||||
|
expect((await login(hono, '203.0.113.1', 'correct-horse-battery')).status).toBe(429)
|
||||||
|
expect((await login(hono, '198.51.100.1', 'correct-horse-battery')).status).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores X-Forwarded-For from a peer that is not a trusted proxy', async () => {
|
||||||
|
const hono = app(localConfig({FLOCI_UI_TRUSTED_PROXIES: NPM_PROXY}))
|
||||||
|
for (let index = 0; index < 5; index++) {
|
||||||
|
await login(hono, '203.0.113.2', 'wrong-password', {'x-forwarded-for': `198.51.100.${index + 10}`})
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((await login(hono, '203.0.113.2', 'correct-horse-battery', {'x-forwarded-for': '198.51.100.99'})).status).toBe(429)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keys clients behind a trusted proxy by their forwarded address', async () => {
|
||||||
|
const hono = app(localConfig({FLOCI_UI_TRUSTED_PROXIES: `${NPM_PROXY}/32`}))
|
||||||
|
await failLogins(hono, NPM_PROXY, 5, {'x-forwarded-for': '203.0.113.3'})
|
||||||
|
|
||||||
|
expect((await login(hono, NPM_PROXY, 'correct-horse-battery', {'x-forwarded-for': '203.0.113.3'})).status).toBe(429)
|
||||||
|
expect((await login(hono, NPM_PROXY, 'correct-horse-battery', {'x-forwarded-for': '198.51.100.3'})).status).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses the right-most untrusted forwarded address so a forged prefix is ignored', async () => {
|
||||||
|
const hono = app(localConfig({FLOCI_UI_TRUSTED_PROXIES: NPM_PROXY}))
|
||||||
|
for (let index = 0; index < 5; index++) {
|
||||||
|
await login(hono, NPM_PROXY, 'wrong-password', {'x-forwarded-for': `10.9.9.${index}, 203.0.113.4`})
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((await login(hono, NPM_PROXY, 'correct-horse-battery', {'x-forwarded-for': '10.9.9.200, 203.0.113.4'})).status).toBe(429)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throttles one address that rotates through usernames', async () => {
|
||||||
|
const hono = app(localConfig())
|
||||||
|
for (let index = 0; index < 20; index++) {
|
||||||
|
await request(hono, '/api/auth/login', '203.0.113.5', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
|
||||||
|
body: new URLSearchParams({username: `user-${index}`, password: 'wrong-password', csrf: 'csrf-token'}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((await login(hono, '203.0.113.5', 'correct-horse-battery')).status).toBe(429)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an invalid trusted proxy entry at startup', () => {
|
||||||
|
expect(() => localConfig({FLOCI_UI_TRUSTED_PROXIES: '192.168.1.0/33'})).toThrow('FLOCI_UI_TRUSTED_PROXIES')
|
||||||
|
expect(() => localConfig({FLOCI_UI_TRUSTED_PROXIES: 'npm.local'})).toThrow('FLOCI_UI_TRUSTED_PROXIES')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('post-login redirect', () => {
|
||||||
|
async function redirectAfterLogin(returnTo: string) {
|
||||||
|
const response = await request(app(localConfig()), '/api/auth/login', '198.51.100.40', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
|
||||||
|
body: new URLSearchParams({username: 'admin', password: 'correct-horse-battery', csrf: 'csrf-token', returnTo}),
|
||||||
|
})
|
||||||
|
return (await response.json() as {redirectTo: string}).redirectTo
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps same-origin paths with their query and fragment', async () => {
|
||||||
|
expect(await redirectAfterLogin('/cloud-explorer/aws/storage?search=logs#top')).toBe('/cloud-explorer/aws/storage?search=logs#top')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses destinations that a browser would resolve to another origin', async () => {
|
||||||
|
for (const returnTo of ['//evil.example', '/\\evil.example', '/\\/evil.example', '/\t/evil.example', 'https://evil.example', '/%0d%0a/evil.example'.replace('%0d%0a', '\r\n')]) {
|
||||||
|
expect(await redirectAfterLogin(returnTo)).toBe('/console/aws')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OIDC authorization', () => {
|
||||||
|
let privateKey: CryptoKey
|
||||||
|
let jwk: JWK
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const pair = await generateKeyPair('RS256')
|
||||||
|
privateKey = pair.privateKey
|
||||||
|
jwk = {...await exportJWK(pair.publicKey), kid: 'key-1', alg: 'RS256', use: 'sig'}
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockIdp(claims: (nonce: string) => JWTPayload, userinfo?: JWTPayload) {
|
||||||
|
let nonce = ''
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const url = String(input instanceof Request ? input.url : input)
|
||||||
|
if (url.endsWith('/.well-known/openid-configuration')) {
|
||||||
|
return Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks', userinfo_endpoint: 'https://idp.test/userinfo'})
|
||||||
|
}
|
||||||
|
if (url === 'https://idp.test/jwks') return Response.json({keys: [jwk]})
|
||||||
|
if (url === 'https://idp.test/token') {
|
||||||
|
expect(new URLSearchParams(String(init?.body)).get('code')).toBe('auth-code')
|
||||||
|
const idToken = await new SignJWT(claims(nonce)).setProtectedHeader({alg: 'RS256', kid: 'key-1'}).setIssuer(ISSUER).setAudience(CLIENT_ID).setIssuedAt().setExpirationTime('5m').sign(privateKey)
|
||||||
|
return Response.json({id_token: idToken, access_token: 'access-token'})
|
||||||
|
}
|
||||||
|
if (url === 'https://idp.test/userinfo' && userinfo) return Response.json(userinfo)
|
||||||
|
return new Response('not found', {status: 404})
|
||||||
|
}) as typeof fetch
|
||||||
|
return {setNonce: (value: string) => { nonce = value }}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(hono: Hono, peer: string, idp: {setNonce: (value: string) => void}) {
|
||||||
|
const start = await request(hono, '/api/auth/oidc/login', peer)
|
||||||
|
const authorize = new URL(start.headers.get('location')!)
|
||||||
|
idp.setNonce(authorize.searchParams.get('nonce')!)
|
||||||
|
return request(hono, `/api/auth/oidc/callback?state=${authorize.searchParams.get('state')}&code=auth-code`, peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('refuses to start in OIDC mode without an allow-list', () => {
|
||||||
|
expect(() => authConfig(oidcEnv())).toThrow('FLOCI_UI_OIDC_ALLOWED_GROUPS')
|
||||||
|
expect(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'})).oidc?.allowAllUsers).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('admits a user in an allowed group', async () => {
|
||||||
|
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
|
||||||
|
const idp = mockIdp((nonce) => ({sub: 'user-1', nonce, preferred_username: 'zak', groups: ['thecloud-admins']}))
|
||||||
|
const callback = await signIn(hono, '203.0.113.20', idp)
|
||||||
|
|
||||||
|
expect(callback.status).toBe(302)
|
||||||
|
expect(callback.headers.get('location')).toBe('/console/aws')
|
||||||
|
expect(callback.headers.get('set-cookie')).toContain('floci_ui_session=')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a user outside the allowed groups', async () => {
|
||||||
|
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
|
||||||
|
const idp = mockIdp((nonce) => ({sub: 'user-2', nonce, preferred_username: 'guest', groups: ['jellyfin-users']}))
|
||||||
|
const callback = await signIn(hono, '203.0.113.21', idp)
|
||||||
|
|
||||||
|
expect(callback.headers.get('location')).toBe('/login?error=forbidden')
|
||||||
|
expect(callback.headers.get('set-cookie') ?? '').not.toContain('floci_ui_session=')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads groups from UserInfo when the ID token omits them', async () => {
|
||||||
|
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
|
||||||
|
const idp = mockIdp((nonce) => ({sub: 'user-3', nonce, preferred_username: 'zak'}), {sub: 'user-3', groups: ['thecloud-admins']})
|
||||||
|
|
||||||
|
expect((await signIn(hono, '203.0.113.22', idp)).headers.get('location')).toBe('/console/aws')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores UserInfo groups that belong to a different subject', async () => {
|
||||||
|
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
|
||||||
|
const idp = mockIdp((nonce) => ({sub: 'user-4', nonce, preferred_username: 'zak'}), {sub: 'someone-else', groups: ['thecloud-admins']})
|
||||||
|
|
||||||
|
expect((await signIn(hono, '203.0.113.23', idp)).headers.get('location')).toBe('/login?error=failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('admits an allowed email only when it is verified', async () => {
|
||||||
|
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_EMAILS: 'Zak@Example.com'})))
|
||||||
|
const unverified = mockIdp((nonce) => ({sub: 'user-5', nonce, email: 'zak@example.com', email_verified: false}))
|
||||||
|
expect((await signIn(hono, '203.0.113.24', unverified)).headers.get('location')).toBe('/login?error=forbidden')
|
||||||
|
|
||||||
|
const verified = mockIdp((nonce) => ({sub: 'user-5', nonce, email: 'zak@example.com', email_verified: true}))
|
||||||
|
expect((await signIn(hono, '203.0.113.24', verified)).headers.get('location')).toBe('/console/aws')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('redirects identity-provider errors and stale callbacks to the login page', async () => {
|
||||||
|
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
|
||||||
|
mockIdp((nonce) => ({sub: 'user-6', nonce}))
|
||||||
|
|
||||||
|
expect((await request(hono, '/api/auth/oidc/callback?error=access_denied&state=x', '203.0.113.25')).headers.get('location')).toBe('/login?error=denied')
|
||||||
|
expect((await request(hono, '/api/auth/oidc/callback?state=unknown&code=auth-code', '203.0.113.25')).headers.get('location')).toBe('/login?error=expired')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('turns a failed token exchange into a login error instead of a server error', async () => {
|
||||||
|
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
|
||||||
|
const idp = mockIdp(() => ({sub: 'user-7', nonce: 'not-the-issued-nonce', groups: ['thecloud-admins']}))
|
||||||
|
|
||||||
|
expect((await signIn(hono, '203.0.113.26', idp)).headers.get('location')).toBe('/login?error=failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throttles repeated sign-in starts from one address', async () => {
|
||||||
|
const hono = app(authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOWED_GROUPS: 'thecloud-admins'})))
|
||||||
|
mockIdp((nonce) => ({sub: 'user-8', nonce}))
|
||||||
|
for (let index = 0; index < 30; index++) await request(hono, '/api/auth/oidc/login', '203.0.113.27')
|
||||||
|
|
||||||
|
expect((await request(hono, '/api/auth/oidc/login', '203.0.113.27')).headers.get('location')).toBe('/login?error=throttled')
|
||||||
|
expect((await request(hono, '/api/auth/oidc/login', '203.0.113.28')).headers.get('location')).toStartWith('https://idp.test/authorize')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth state cleanup', () => {
|
||||||
|
test('caps pending OIDC requests and sweeps expired state', async () => {
|
||||||
|
const config = authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'}))
|
||||||
|
const hono = app(config)
|
||||||
|
globalThis.fetch = (async () => Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks'})) as unknown as typeof fetch
|
||||||
|
for (let index = 0; index < 1_050; index++) await request(hono, '/api/auth/oidc/login', `10.1.${Math.floor(index / 250)}.${index % 250}`)
|
||||||
|
|
||||||
|
expect(authStateSizes().oidcRequests).toBe(1_000)
|
||||||
|
|
||||||
|
sweepExpired(config, Date.now() + 24 * 60 * 60_000)
|
||||||
|
expect(authStateSizes()).toEqual({sessions: 0, attempts: 0, oidcRequests: 0})
|
||||||
|
})
|
||||||
|
})
|
||||||
+163
-35
@@ -1,15 +1,27 @@
|
|||||||
import {timingSafeEqual} from 'node:crypto'
|
import {timingSafeEqual} from 'node:crypto'
|
||||||
import {createRemoteJWKSet, jwtVerify} from 'jose'
|
import {BlockList, isIP} from 'node:net'
|
||||||
|
import {createRemoteJWKSet, jwtVerify, type JWTPayload} from 'jose'
|
||||||
import type {Context, MiddlewareHandler} from 'hono'
|
import type {Context, MiddlewareHandler} from 'hono'
|
||||||
|
import {getConnInfo} from 'hono/bun'
|
||||||
import {deleteCookie, getCookie, setCookie} from 'hono/cookie'
|
import {deleteCookie, getCookie, setCookie} from 'hono/cookie'
|
||||||
import {renderLoginPage} from './loginPage.ts'
|
import {renderLoginPage} from './loginPage'
|
||||||
|
|
||||||
const SESSION_COOKIE = 'floci_ui_session'
|
const SESSION_COOKIE = 'floci_ui_session'
|
||||||
const CSRF_COOKIE = 'floci_ui_csrf'
|
const CSRF_COOKIE = 'floci_ui_csrf'
|
||||||
const LOGIN_WINDOW_MS = 15 * 60_000
|
const LOGIN_WINDOW_MS = 15 * 60_000
|
||||||
const LOGIN_ATTEMPTS = 5
|
const LOGIN_ATTEMPTS = 5
|
||||||
|
const LOGIN_ATTEMPTS_PER_IP = 20
|
||||||
|
const OIDC_STARTS_PER_IP = 30
|
||||||
|
const OIDC_REQUEST_TTL_MS = 10 * 60_000
|
||||||
|
const OIDC_DISCOVERY_TTL_MS = 60 * 60_000
|
||||||
|
const SWEEP_INTERVAL_MS = 60_000
|
||||||
|
const MAX_SESSIONS = 1_000
|
||||||
|
const MAX_OIDC_REQUESTS = 1_000
|
||||||
|
const MAX_ATTEMPTS = 10_000
|
||||||
|
|
||||||
type AuthConfig = {
|
type OidcConfig = {issuer: string; clientId: string; clientSecret: string; publicBaseUrl: string; allowedGroups: string[]; allowedEmails: string[]; allowAllUsers: boolean}
|
||||||
|
|
||||||
|
export type AuthConfig = {
|
||||||
mode: 'local' | 'oidc'
|
mode: 'local' | 'oidc'
|
||||||
adminUser?: string
|
adminUser?: string
|
||||||
passwordHash?: string
|
passwordHash?: string
|
||||||
@@ -17,15 +29,19 @@ type AuthConfig = {
|
|||||||
idleTimeoutMs: number
|
idleTimeoutMs: number
|
||||||
absoluteTimeoutMs: number
|
absoluteTimeoutMs: number
|
||||||
secureCookies: boolean
|
secureCookies: boolean
|
||||||
oidc?: {issuer: string; clientId: string; clientSecret: string; publicBaseUrl: string}
|
trustedProxies: BlockList | null
|
||||||
|
oidc?: OidcConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type Session = {username: string; createdAt: number; lastSeenAt: number; csrfToken: string}
|
type Session = {username: string; createdAt: number; lastSeenAt: number; csrfToken: string}
|
||||||
type Attempt = {count: number; firstAt: number; lockedUntil: number}
|
type Attempt = {count: number; firstAt: number; lockedUntil: number}
|
||||||
|
type OidcDiscovery = {authorization_endpoint: string; token_endpoint: string; jwks_uri: string; userinfo_endpoint?: string}
|
||||||
|
|
||||||
const sessions = new Map<string, Session>()
|
const sessions = new Map<string, Session>()
|
||||||
const attempts = new Map<string, Attempt>()
|
const attempts = new Map<string, Attempt>()
|
||||||
const oidcRequests = new Map<string, {nonce: string; verifier: string; returnTo: string; expiresAt: number}>()
|
const oidcRequests = new Map<string, {nonce: string; verifier: string; returnTo: string; expiresAt: number}>()
|
||||||
|
const discoveries = new Map<string, {document: OidcDiscovery; expiresAt: number}>()
|
||||||
|
const jwksSets = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
|
||||||
|
|
||||||
export function authConfig(env = process.env): AuthConfig {
|
export function authConfig(env = process.env): AuthConfig {
|
||||||
const mode = (env.FLOCI_UI_AUTH_MODE ?? 'local') as 'local' | 'oidc'
|
const mode = (env.FLOCI_UI_AUTH_MODE ?? 'local') as 'local' | 'oidc'
|
||||||
@@ -35,17 +51,26 @@ export function authConfig(env = process.env): AuthConfig {
|
|||||||
const idleTimeoutMs = seconds(env.FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS, 1800)
|
const idleTimeoutMs = seconds(env.FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS, 1800)
|
||||||
const absoluteTimeoutMs = seconds(env.FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS, 28_800)
|
const absoluteTimeoutMs = seconds(env.FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS, 28_800)
|
||||||
if (idleTimeoutMs > absoluteTimeoutMs) throw new Error('FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS cannot exceed FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS')
|
if (idleTimeoutMs > absoluteTimeoutMs) throw new Error('FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS cannot exceed FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS')
|
||||||
|
const secureCookies = env.FLOCI_UI_SECURE_COOKIES !== 'false'
|
||||||
|
const trustedProxies = parseTrustedProxies(env.FLOCI_UI_TRUSTED_PROXIES)
|
||||||
if (mode === 'oidc') {
|
if (mode === 'oidc') {
|
||||||
const issuer = `${required(env.FLOCI_UI_OIDC_ISSUER, 'FLOCI_UI_OIDC_ISSUER').replace(/\/$/, '')}/`
|
const issuer = `${required(env.FLOCI_UI_OIDC_ISSUER, 'FLOCI_UI_OIDC_ISSUER').replace(/\/$/, '')}/`
|
||||||
return {mode, secret, idleTimeoutMs, absoluteTimeoutMs, secureCookies: env.FLOCI_UI_SECURE_COOKIES !== 'false', oidc: {issuer, clientId: required(env.FLOCI_UI_OIDC_CLIENT_ID, 'FLOCI_UI_OIDC_CLIENT_ID'), clientSecret: required(env.FLOCI_UI_OIDC_CLIENT_SECRET, 'FLOCI_UI_OIDC_CLIENT_SECRET'), publicBaseUrl: required(env.FLOCI_UI_PUBLIC_BASE_URL, 'FLOCI_UI_PUBLIC_BASE_URL').replace(/\/$/, '')}}
|
const allowedGroups = list(env.FLOCI_UI_OIDC_ALLOWED_GROUPS)
|
||||||
|
const allowedEmails = list(env.FLOCI_UI_OIDC_ALLOWED_EMAILS).map((email) => email.toLowerCase())
|
||||||
|
const allowAllUsers = env.FLOCI_UI_OIDC_ALLOW_ALL_USERS === 'true'
|
||||||
|
if (!allowAllUsers && allowedGroups.length === 0 && allowedEmails.length === 0) {
|
||||||
|
throw new Error('OIDC mode requires FLOCI_UI_OIDC_ALLOWED_GROUPS or FLOCI_UI_OIDC_ALLOWED_EMAILS (or FLOCI_UI_OIDC_ALLOW_ALL_USERS=true to admit every identity-provider user)')
|
||||||
|
}
|
||||||
|
return {mode, secret, idleTimeoutMs, absoluteTimeoutMs, secureCookies, trustedProxies, oidc: {issuer, clientId: required(env.FLOCI_UI_OIDC_CLIENT_ID, 'FLOCI_UI_OIDC_CLIENT_ID'), clientSecret: required(env.FLOCI_UI_OIDC_CLIENT_SECRET, 'FLOCI_UI_OIDC_CLIENT_SECRET'), publicBaseUrl: required(env.FLOCI_UI_PUBLIC_BASE_URL, 'FLOCI_UI_PUBLIC_BASE_URL').replace(/\/$/, ''), allowedGroups, allowedEmails, allowAllUsers}}
|
||||||
}
|
}
|
||||||
const adminUser = required(env.FLOCI_UI_ADMIN_USER, 'FLOCI_UI_ADMIN_USER')
|
const adminUser = required(env.FLOCI_UI_ADMIN_USER, 'FLOCI_UI_ADMIN_USER')
|
||||||
const passwordHash = required(env.FLOCI_UI_ADMIN_PASSWORD_HASH, 'FLOCI_UI_ADMIN_PASSWORD_HASH')
|
const passwordHash = required(env.FLOCI_UI_ADMIN_PASSWORD_HASH, 'FLOCI_UI_ADMIN_PASSWORD_HASH')
|
||||||
if (!passwordHash.startsWith('$argon2id$') && !passwordHash.startsWith('$2')) throw new Error('FLOCI_UI_ADMIN_PASSWORD_HASH must be an argon2id or bcrypt hash')
|
if (!passwordHash.startsWith('$argon2id$') && !passwordHash.startsWith('$2')) throw new Error('FLOCI_UI_ADMIN_PASSWORD_HASH must be an argon2id or bcrypt hash')
|
||||||
return {mode, adminUser, passwordHash, secret, idleTimeoutMs, absoluteTimeoutMs, secureCookies: env.FLOCI_UI_SECURE_COOKIES !== 'false'}
|
return {mode, adminUser, passwordHash, secret, idleTimeoutMs, absoluteTimeoutMs, secureCookies, trustedProxies}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authMiddleware(config: AuthConfig): MiddlewareHandler {
|
export function authMiddleware(config: AuthConfig): MiddlewareHandler {
|
||||||
|
setInterval(() => sweepExpired(config), SWEEP_INTERVAL_MS).unref()
|
||||||
return async (c, next) => {
|
return async (c, next) => {
|
||||||
const styleNonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url')
|
const styleNonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url')
|
||||||
c.set('styleNonce', styleNonce)
|
c.set('styleNonce', styleNonce)
|
||||||
@@ -81,29 +106,41 @@ async function login(c: Context, config: AuthConfig): Promise<Response> {
|
|||||||
const password = typeof form.password === 'string' ? form.password : ''
|
const password = typeof form.password === 'string' ? form.password : ''
|
||||||
const csrf = typeof form.csrf === 'string' ? form.csrf : c.req.header('x-floci-csrf') ?? ''
|
const csrf = typeof form.csrf === 'string' ? form.csrf : c.req.header('x-floci-csrf') ?? ''
|
||||||
if (!sameToken(getCookie(c, CSRF_COOKIE), csrf)) return loginFailure(c, 403)
|
if (!sameToken(getCookie(c, CSRF_COOKIE), csrf)) return loginFailure(c, 403)
|
||||||
const key = `${clientIp(c)}:${username.toLowerCase()}`
|
const ip = clientIp(c, config)
|
||||||
if (locked(key)) return loginFailure(c, 429)
|
const key = `login:${ip}:${username.toLowerCase()}`
|
||||||
|
const ipKey = `login-ip:${ip}`
|
||||||
|
if (locked(key) || locked(ipKey)) return loginFailure(c, 429)
|
||||||
const usernameMatches = sameToken(username, config.adminUser)
|
const usernameMatches = sameToken(username, config.adminUser)
|
||||||
const passwordMatches = await Bun.password.verify(password, config.passwordHash!)
|
const passwordMatches = await Bun.password.verify(password, config.passwordHash!)
|
||||||
if (!usernameMatches || !passwordMatches) {
|
if (!usernameMatches || !passwordMatches) {
|
||||||
recordFailure(key)
|
recordFailure(key, LOGIN_ATTEMPTS)
|
||||||
|
recordFailure(ipKey, LOGIN_ATTEMPTS_PER_IP)
|
||||||
return loginFailure(c, 401)
|
return loginFailure(c, 401)
|
||||||
}
|
}
|
||||||
attempts.delete(key)
|
attempts.delete(key)
|
||||||
await createSession(c, config, config.adminUser!)
|
if (!await createSession(c, config, config.adminUser!)) return c.json({error: 'Too many active sessions. Try again later.'}, 503)
|
||||||
const redirectTo = safeReturnTo(typeof form.returnTo === 'string' ? form.returnTo : undefined)
|
const redirectTo = safeReturnTo(typeof form.returnTo === 'string' ? form.returnTo : undefined)
|
||||||
return acceptsHtml(c) ? c.redirect(redirectTo, 302) : c.json({ok: true, redirectTo})
|
return acceptsHtml(c) ? c.redirect(redirectTo, 302) : c.json({ok: true, redirectTo})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function oidcLogin(c: Context, config: AuthConfig): Promise<Response> {
|
async function oidcLogin(c: Context, config: AuthConfig): Promise<Response> {
|
||||||
if (!config.oidc) return c.notFound()
|
if (!config.oidc) return c.notFound()
|
||||||
const discovery = await oidcDiscovery(config.oidc.issuer)
|
const startKey = `oidc-start:${clientIp(c, config)}`
|
||||||
|
if (locked(startKey)) return c.redirect('/login?error=throttled', 302)
|
||||||
|
recordFailure(startKey, OIDC_STARTS_PER_IP)
|
||||||
|
let discovery: OidcDiscovery
|
||||||
|
try {
|
||||||
|
discovery = await oidcDiscovery(config.oidc.issuer)
|
||||||
|
} catch {
|
||||||
|
return c.redirect('/login?error=unavailable', 302)
|
||||||
|
}
|
||||||
const state = crypto.randomUUID()
|
const state = crypto.randomUUID()
|
||||||
const nonce = crypto.randomUUID()
|
const nonce = crypto.randomUUID()
|
||||||
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
|
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
|
||||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
||||||
const challenge = Buffer.from(digest).toString('base64url')
|
const challenge = Buffer.from(digest).toString('base64url')
|
||||||
oidcRequests.set(state, {nonce, verifier, returnTo: safeReturnTo(c.req.query('returnTo')), expiresAt: Date.now() + 10 * 60_000})
|
if (oidcRequests.size >= MAX_OIDC_REQUESTS) oidcRequests.delete(oidcRequests.keys().next().value!)
|
||||||
|
oidcRequests.set(state, {nonce, verifier, returnTo: safeReturnTo(c.req.query('returnTo')), expiresAt: Date.now() + OIDC_REQUEST_TTL_MS})
|
||||||
const url = new URL(discovery.authorization_endpoint)
|
const url = new URL(discovery.authorization_endpoint)
|
||||||
url.search = new URLSearchParams({response_type: 'code', client_id: config.oidc.clientId, redirect_uri: `${config.oidc.publicBaseUrl}/api/auth/oidc/callback`, scope: 'openid profile email', state, nonce, code_challenge: challenge, code_challenge_method: 'S256'}).toString()
|
url.search = new URLSearchParams({response_type: 'code', client_id: config.oidc.clientId, redirect_uri: `${config.oidc.publicBaseUrl}/api/auth/oidc/callback`, scope: 'openid profile email', state, nonce, code_challenge: challenge, code_challenge_method: 'S256'}).toString()
|
||||||
return c.redirect(url.toString(), 302)
|
return c.redirect(url.toString(), 302)
|
||||||
@@ -112,29 +149,75 @@ async function oidcLogin(c: Context, config: AuthConfig): Promise<Response> {
|
|||||||
async function oidcCallback(c: Context, config: AuthConfig): Promise<Response> {
|
async function oidcCallback(c: Context, config: AuthConfig): Promise<Response> {
|
||||||
if (!config.oidc) return c.notFound()
|
if (!config.oidc) return c.notFound()
|
||||||
const state = c.req.query('state')
|
const state = c.req.query('state')
|
||||||
const code = c.req.query('code')
|
|
||||||
const request = state ? oidcRequests.get(state) : undefined
|
const request = state ? oidcRequests.get(state) : undefined
|
||||||
if (!state || !code || !request || request.expiresAt < Date.now()) return c.json({error: 'Invalid OIDC callback'}, 400)
|
if (state) oidcRequests.delete(state)
|
||||||
oidcRequests.delete(state)
|
if (c.req.query('error')) return c.redirect(`/login?error=${c.req.query('error') === 'access_denied' ? 'denied' : 'failed'}`, 302)
|
||||||
const discovery = await oidcDiscovery(config.oidc.issuer)
|
const code = c.req.query('code')
|
||||||
const tokenResponse = await fetch(discovery.token_endpoint, {method: 'POST', headers: {'content-type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({grant_type: 'authorization_code', code, redirect_uri: `${config.oidc.publicBaseUrl}/api/auth/oidc/callback`, client_id: config.oidc.clientId, client_secret: config.oidc.clientSecret, code_verifier: request.verifier})})
|
if (!code || !request || request.expiresAt < Date.now()) return c.redirect('/login?error=expired', 302)
|
||||||
const tokens = await tokenResponse.json() as {id_token?: string}
|
let claims: JWTPayload
|
||||||
if (!tokenResponse.ok || !tokens.id_token) return c.json({error: 'OIDC token exchange failed'}, 401)
|
try {
|
||||||
const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri))
|
claims = await verifiedOidcClaims(config.oidc, code, request)
|
||||||
const verified = await jwtVerify(tokens.id_token, jwks, {issuer: config.oidc.issuer, audience: config.oidc.clientId})
|
} catch {
|
||||||
if (verified.payload.nonce !== request.nonce) return c.json({error: 'OIDC nonce validation failed'}, 401)
|
return c.redirect('/login?error=failed', 302)
|
||||||
const username = String(verified.payload.preferred_username ?? verified.payload.email ?? verified.payload.sub ?? '')
|
}
|
||||||
if (!username) return c.json({error: 'OIDC identity has no usable subject'}, 401)
|
if (!oidcUserAllowed(claims, config.oidc)) return c.redirect('/login?error=forbidden', 302)
|
||||||
await createSession(c, config, username)
|
const username = String(claims.preferred_username ?? claims.email ?? claims.sub ?? '')
|
||||||
|
if (!username) return c.redirect('/login?error=failed', 302)
|
||||||
|
if (!await createSession(c, config, username)) return c.redirect('/login?error=unavailable', 302)
|
||||||
|
attempts.delete(`oidc-start:${clientIp(c, config)}`)
|
||||||
return c.redirect(request.returnTo, 302)
|
return c.redirect(request.returnTo, 302)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSession(c: Context, config: AuthConfig, username: string): Promise<void> {
|
async function verifiedOidcClaims(oidc: OidcConfig, code: string, request: {nonce: string; verifier: string}): Promise<JWTPayload> {
|
||||||
|
const discovery = await oidcDiscovery(oidc.issuer)
|
||||||
|
const tokenResponse = await fetch(discovery.token_endpoint, {method: 'POST', headers: {'content-type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({grant_type: 'authorization_code', code, redirect_uri: `${oidc.publicBaseUrl}/api/auth/oidc/callback`, client_id: oidc.clientId, client_secret: oidc.clientSecret, code_verifier: request.verifier})})
|
||||||
|
const tokens = await tokenResponse.json() as {id_token?: string; access_token?: string}
|
||||||
|
if (!tokenResponse.ok || !tokens.id_token) throw new Error('OIDC token exchange failed')
|
||||||
|
const {payload} = await jwtVerify(tokens.id_token, jwksFor(discovery.jwks_uri), {issuer: oidc.issuer, audience: oidc.clientId})
|
||||||
|
if (payload.nonce !== request.nonce) throw new Error('OIDC nonce validation failed')
|
||||||
|
if (oidc.allowedGroups.length === 0 || 'groups' in payload || !tokens.access_token || !discovery.userinfo_endpoint) return payload
|
||||||
|
// Providers may leave profile claims such as `groups` out of the ID token; the
|
||||||
|
// UserInfo response is authoritative for them once its `sub` matches.
|
||||||
|
const userinfoResponse = await fetch(discovery.userinfo_endpoint, {headers: {authorization: `Bearer ${tokens.access_token}`}})
|
||||||
|
if (!userinfoResponse.ok) throw new Error('OIDC userinfo request failed')
|
||||||
|
const userinfo = await userinfoResponse.json() as JWTPayload
|
||||||
|
if (userinfo.sub !== payload.sub) throw new Error('OIDC userinfo subject mismatch')
|
||||||
|
return {...userinfo, ...payload, groups: userinfo.groups}
|
||||||
|
}
|
||||||
|
|
||||||
|
function oidcUserAllowed(claims: JWTPayload, oidc: OidcConfig): boolean {
|
||||||
|
if (oidc.allowAllUsers) return true
|
||||||
|
const groups = Array.isArray(claims.groups) ? claims.groups.filter((group): group is string => typeof group === 'string') : []
|
||||||
|
if (groups.some((group) => oidc.allowedGroups.includes(group))) return true
|
||||||
|
const email = typeof claims.email === 'string' ? claims.email.toLowerCase() : ''
|
||||||
|
return claims.email_verified === true && email !== '' && oidc.allowedEmails.includes(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession(c: Context, config: AuthConfig, username: string): Promise<boolean> {
|
||||||
|
if (sessions.size >= MAX_SESSIONS) sweepExpired(config)
|
||||||
|
if (sessions.size >= MAX_SESSIONS) return false
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const csrfToken = crypto.randomUUID()
|
const csrfToken = crypto.randomUUID()
|
||||||
sessions.set(id, {username, createdAt: Date.now(), lastSeenAt: Date.now(), csrfToken})
|
sessions.set(id, {username, createdAt: Date.now(), lastSeenAt: Date.now(), csrfToken})
|
||||||
setCookie(c, SESSION_COOKIE, `${id}.${await signature(id, config.secret)}`, cookieOptions(config))
|
setCookie(c, SESSION_COOKIE, `${id}.${await signature(id, config.secret)}`, cookieOptions(config))
|
||||||
setCookie(c, CSRF_COOKIE, csrfToken, {...cookieOptions(config), httpOnly: false})
|
setCookie(c, CSRF_COOKIE, csrfToken, {...cookieOptions(config), httpOnly: false})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sweepExpired(config: AuthConfig, now = Date.now()): void {
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
if (now - session.lastSeenAt > config.idleTimeoutMs || now - session.createdAt > config.absoluteTimeoutMs) sessions.delete(id)
|
||||||
|
}
|
||||||
|
for (const [state, request] of oidcRequests) {
|
||||||
|
if (request.expiresAt < now) oidcRequests.delete(state)
|
||||||
|
}
|
||||||
|
for (const [key, attempt] of attempts) {
|
||||||
|
if (attempt.lockedUntil <= now && now - attempt.firstAt > LOGIN_WINDOW_MS) attempts.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authStateSizes(): {sessions: number; attempts: number; oidcRequests: number} {
|
||||||
|
return {sessions: sessions.size, attempts: attempts.size, oidcRequests: oidcRequests.size}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout(c: Context, config: AuthConfig): Promise<Response> {
|
async function logout(c: Context, config: AuthConfig): Promise<Response> {
|
||||||
@@ -199,25 +282,70 @@ function loginErrorMessage(code: string | undefined): string | null {
|
|||||||
if (!code) return null
|
if (!code) return null
|
||||||
if (code === 'denied') return 'Your sign-in was cancelled. Please try again when you are ready.'
|
if (code === 'denied') return 'Your sign-in was cancelled. Please try again when you are ready.'
|
||||||
if (code === 'expired') return 'Your sign-in session expired. Please start again.'
|
if (code === 'expired') return 'Your sign-in session expired. Please start again.'
|
||||||
|
if (code === 'forbidden') return 'Your account is not allowed to access this console.'
|
||||||
|
if (code === 'throttled') return 'Too many sign-in attempts. Try again later.'
|
||||||
|
if (code === 'unavailable') return 'Sign-in is temporarily unavailable. Please try again shortly.'
|
||||||
return 'We could not complete your sign-in. Please try again.'
|
return 'We could not complete your sign-in. Please try again.'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPublic(path: string): boolean { return path === '/login' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
|
function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
|
||||||
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
|
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
|
||||||
function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' }
|
// Browsers treat `\` like `/` and drop tabs/newlines in URLs, so `/\evil.example` would
|
||||||
|
// otherwise pass a plain prefix check and land on another origin.
|
||||||
|
function safeReturnTo(value?: string): string {
|
||||||
|
const fallback = '/console/aws'
|
||||||
|
if (!value || !value.startsWith('/') || /[\\\u0000-\u001f\u007f]/.test(value)) return fallback
|
||||||
|
const base = 'http://return-to.invalid'
|
||||||
|
try {
|
||||||
|
const url = new URL(value, base)
|
||||||
|
return url.origin === base ? `${url.pathname}${url.search}${url.hash}` : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) }
|
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) }
|
||||||
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
|
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
|
||||||
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
|
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
|
||||||
function cookieOptions(config: AuthConfig) { return {httpOnly: true, secure: config.secureCookies, sameSite: 'Lax' as const, path: '/', maxAge: Math.floor(config.absoluteTimeoutMs / 1000)} }
|
function cookieOptions(config: AuthConfig) { return {httpOnly: true, secure: config.secureCookies, sameSite: 'Lax' as const, path: '/', maxAge: Math.floor(config.absoluteTimeoutMs / 1000)} }
|
||||||
function seconds(value: string | undefined, fallback: number): number { const parsed = Number(value ?? fallback); if (!Number.isInteger(parsed) || parsed < 60) throw new Error('Session timeout values must be whole seconds of at least 60'); return parsed * 1000 }
|
function seconds(value: string | undefined, fallback: number): number { const parsed = Number(value ?? fallback); if (!Number.isInteger(parsed) || parsed < 60) throw new Error('Session timeout values must be whole seconds of at least 60'); return parsed * 1000 }
|
||||||
function required(value: string | undefined, name: string): string { if (!value?.trim()) throw new Error(`${name} is required`); return value }
|
function required(value: string | undefined, name: string): string { if (!value?.trim()) throw new Error(`${name} is required`); return value }
|
||||||
async function oidcDiscovery(issuer: string): Promise<{authorization_endpoint: string; token_endpoint: string; jwks_uri: string}> { const response = await fetch(`${issuer}.well-known/openid-configuration`); if (!response.ok) throw new Error('Unable to load OIDC discovery document'); return response.json() }
|
function list(value: string | undefined): string[] { return (value ?? '').split(',').map((entry) => entry.trim()).filter(Boolean) }
|
||||||
|
async function oidcDiscovery(issuer: string): Promise<OidcDiscovery> { const cached = discoveries.get(issuer); if (cached && cached.expiresAt > Date.now()) return cached.document; const response = await fetch(`${issuer}.well-known/openid-configuration`); if (!response.ok) throw new Error('Unable to load OIDC discovery document'); const document = await response.json() as OidcDiscovery; discoveries.set(issuer, {document, expiresAt: Date.now() + OIDC_DISCOVERY_TTL_MS}); return document }
|
||||||
|
function jwksFor(uri: string): ReturnType<typeof createRemoteJWKSet> { let jwks = jwksSets.get(uri); if (!jwks) { jwks = createRemoteJWKSet(new URL(uri)); jwksSets.set(uri, jwks) } return jwks }
|
||||||
async function signature(value: string, secret: string): Promise<string> { const bytes = await crypto.subtle.sign('HMAC', await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']), new TextEncoder().encode(value)); return Buffer.from(bytes).toString('base64url') }
|
async function signature(value: string, secret: string): Promise<string> { const bytes = await crypto.subtle.sign('HMAC', await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']), new TextEncoder().encode(value)); return Buffer.from(bytes).toString('base64url') }
|
||||||
function sameToken(left: string | undefined, right: string | undefined): boolean { if (!left || !right) return false; const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && timingSafeEqual(a, b) }
|
function sameToken(left: string | undefined, right: string | undefined): boolean { if (!left || !right) return false; const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && timingSafeEqual(a, b) }
|
||||||
function locked(key: string): boolean { const attempt = attempts.get(key); return Boolean(attempt && attempt.lockedUntil > Date.now()) }
|
function locked(key: string): boolean { const attempt = attempts.get(key); return Boolean(attempt && attempt.lockedUntil > Date.now()) }
|
||||||
function recordFailure(key: string): void { const now = Date.now(); const attempt = attempts.get(key); const current = !attempt || now - attempt.firstAt > LOGIN_WINDOW_MS ? {count: 0, firstAt: now, lockedUntil: 0} : attempt; current.count++; if (current.count >= LOGIN_ATTEMPTS) current.lockedUntil = now + Math.min(15 * 60_000, 30_000 * 2 ** (current.count - LOGIN_ATTEMPTS)); attempts.set(key, current) }
|
function recordFailure(key: string, limit: number): void { const now = Date.now(); const attempt = attempts.get(key); const current = !attempt || now - attempt.firstAt > LOGIN_WINDOW_MS ? {count: 0, firstAt: now, lockedUntil: 0} : attempt; current.count++; if (current.count >= limit) current.lockedUntil = now + Math.min(15 * 60_000, 30_000 * 2 ** (current.count - limit)); if (!attempts.has(key) && attempts.size >= MAX_ATTEMPTS) attempts.delete(attempts.keys().next().value!); attempts.set(key, current) }
|
||||||
// Hono's portable Request API does not expose the peer address. Do not trust a
|
// X-Forwarded-For is client-controlled, so it is only read when the TCP peer is an
|
||||||
// client-supplied X-Forwarded-For value until the Bun server is wired to verify
|
// allow-listed proxy. The right-most address not belonging to a trusted proxy is the
|
||||||
// it against an explicit proxy address allow-list.
|
// one the outermost trusted proxy actually saw; anything left of it may be forged.
|
||||||
function clientIp(_c: Context): string { return 'unknown' }
|
function clientIp(c: Context, config: AuthConfig): string {
|
||||||
function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-mgwziK4YXYUt2nNB711bt/nNzs3je0rpErGfX/6eqmc='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') }
|
const peer = peerAddress(c)
|
||||||
|
if (!peer || !trusted(peer, config.trustedProxies)) return peer ?? 'unknown'
|
||||||
|
const forwarded = (c.req.header('x-forwarded-for') ?? '').split(',').map((entry) => normalizeIp(entry.trim()))
|
||||||
|
for (let index = forwarded.length - 1; index >= 0; index--) {
|
||||||
|
const address = forwarded[index]
|
||||||
|
if (!isIP(address)) return peer
|
||||||
|
if (!trusted(address, config.trustedProxies)) return address
|
||||||
|
}
|
||||||
|
return peer
|
||||||
|
}
|
||||||
|
function peerAddress(c: Context): string | undefined { try { const address = getConnInfo(c).remote.address; return address ? normalizeIp(address) : undefined } catch { return undefined } }
|
||||||
|
function normalizeIp(address: string): string { return /^::ffff:\d+\.\d+\.\d+\.\d+$/i.test(address) ? address.slice(7) : address }
|
||||||
|
function trusted(address: string, proxies: BlockList | null): boolean { return Boolean(proxies?.check(address, isIP(address) === 6 ? 'ipv6' : 'ipv4')) }
|
||||||
|
function parseTrustedProxies(value: string | undefined): BlockList | null {
|
||||||
|
const entries = list(value)
|
||||||
|
if (entries.length === 0) return null
|
||||||
|
const proxies = new BlockList()
|
||||||
|
for (const entry of entries) {
|
||||||
|
const [address, prefix] = entry.split('/')
|
||||||
|
const family = isIP(address)
|
||||||
|
const bits = prefix === undefined ? undefined : Number(prefix)
|
||||||
|
if (!family || (bits !== undefined && (!Number.isInteger(bits) || bits < 0 || bits > (family === 6 ? 128 : 32)))) throw new Error(`FLOCI_UI_TRUSTED_PROXIES has an invalid IP or CIDR: ${entry}`)
|
||||||
|
const type = family === 6 ? 'ipv6' : 'ipv4'
|
||||||
|
if (bits === undefined) proxies.addAddress(address, type)
|
||||||
|
else proxies.addSubnet(address, bits, type)
|
||||||
|
}
|
||||||
|
return proxies
|
||||||
|
}
|
||||||
|
function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-AbRSUfgtaJwJXaS+/FXX4ATIx390dHnVN1Z7G7jqB8s='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') }
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {describe, expect, test} from 'bun:test'
|
||||||
|
import server from './index'
|
||||||
|
|
||||||
|
describe('API surface hardening', () => {
|
||||||
|
test('serves an unauthenticated JSON health check', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/health'))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({status: 'ok'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('answers unknown API paths with JSON instead of the SPA shell', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/does-not-exist'))
|
||||||
|
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/json')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an oversized sign-in body before parsing it', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
|
||||||
|
body: `username=admin&password=${'x'.repeat(20 * 1024)}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(413)
|
||||||
|
expect(await res.json()).toEqual({error: 'Request body is too large'})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the upload ceiling for object uploads', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/storage/resources/bucket-1/object?key=big.bin', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'content-type': 'application/octet-stream'},
|
||||||
|
body: new Uint8Array(11 * 1024 * 1024),
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('applies the default limit to other API routes', async () => {
|
||||||
|
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/messaging/resources/queue-1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: new Uint8Array(11 * 1024 * 1024),
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(res.status).toBe(413)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import {authConfig, authMiddleware, authRoutes} from './auth';
|
import {authConfig, authMiddleware, authRoutes} from './auth';
|
||||||
@@ -44,9 +45,19 @@ app.use(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use("*", logger());
|
app.use("*", logger());
|
||||||
|
// Sign-in endpoints read the body before a session exists, so they get a small
|
||||||
|
// limit; uploads (S3 objects, Lambda ZIPs sent as base64, Lightsail bundles) keep
|
||||||
|
// Bun's default 128 MB ceiling.
|
||||||
|
app.use("/api/*", (c, next) =>
|
||||||
|
bodyLimit({
|
||||||
|
maxSize: requestBodyLimit(c.req.path),
|
||||||
|
onError: (c) => c.json({ error: "Request body is too large" }, 413),
|
||||||
|
})(c, next),
|
||||||
|
);
|
||||||
app.use("*", authMiddleware(authentication));
|
app.use("*", authMiddleware(authentication));
|
||||||
app.use("*", authRoutes(authentication));
|
app.use("*", authRoutes(authentication));
|
||||||
|
|
||||||
|
app.get("/api/health", (c) => c.json({ status: "ok" }));
|
||||||
app.route("/api/eks", eks);
|
app.route("/api/eks", eks);
|
||||||
app.route("/api/ec2", ec2);
|
app.route("/api/ec2", ec2);
|
||||||
app.route("/api/secretsmanager", secretsmanager);
|
app.route("/api/secretsmanager", secretsmanager);
|
||||||
@@ -55,6 +66,7 @@ app.route("/api/msk", msk);
|
|||||||
app.route("/api/msk-connect", mskConnect);
|
app.route("/api/msk-connect", mskConnect);
|
||||||
app.route("/api/ses", ses);
|
app.route("/api/ses", ses);
|
||||||
app.route("/api/elasticache", elasticache);
|
app.route("/api/elasticache", elasticache);
|
||||||
|
app.all("/api/*", (c) => c.json({ error: "Not found" }, 404));
|
||||||
|
|
||||||
// Serve static frontend files when public/ directory is present (production)
|
// Serve static frontend files when public/ directory is present (production)
|
||||||
app.use("*", serveStatic({ root: "./public" }));
|
app.use("*", serveStatic({ root: "./public" }));
|
||||||
@@ -63,6 +75,16 @@ app.get("*", serveStatic({ path: "./public/index.html" }));
|
|||||||
const port = Number(process.env.PORT ?? 4501);
|
const port = Number(process.env.PORT ?? 4501);
|
||||||
export default { port, fetch: app.fetch };
|
export default { port, fetch: app.fetch };
|
||||||
|
|
||||||
|
function requestBodyLimit(path: string): number {
|
||||||
|
if (path.startsWith("/api/auth/")) return 16 * 1024;
|
||||||
|
if (
|
||||||
|
/^\/api\/clouds\/[^/]+\/services\/[^/]+\/resources(?:\/[^/]+(?:\/object|\/static-files|\/node-application)?)?$/.test(path)
|
||||||
|
) {
|
||||||
|
return 128 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
return 10 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
function isSensitiveCredentialRoute(path: string): boolean {
|
function isSensitiveCredentialRoute(path: string): boolean {
|
||||||
return (
|
return (
|
||||||
path.startsWith("/api/secretsmanager") ||
|
path.startsWith("/api/secretsmanager") ||
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function renderLoginPage(options: LoginPageOptions): string {
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<link rel="icon" href="/TheCloud.png" />
|
||||||
<title>Sign in · TheCloud</title>
|
<title>Sign in · TheCloud</title>
|
||||||
<style nonce="${options.styleNonce}">
|
<style nonce="${options.styleNonce}">
|
||||||
:root[data-theme=dark]{--bg:#07111d;--rail:#091727;--panel:#0c1a2b;--fg:#e8f1fa;--dim:#bdd0e3;--mut:#8aa3bc;--ac:#6cc4ff;--acr:108,196,255;--ac2:#ffb547;--bd:rgba(232,241,250,.1);--bd2:rgba(232,241,250,.2);--on:#06223a;--err:#ff9a9a;--errbg:#3a1822}
|
:root[data-theme=dark]{--bg:#07111d;--rail:#091727;--panel:#0c1a2b;--fg:#e8f1fa;--dim:#bdd0e3;--mut:#8aa3bc;--ac:#6cc4ff;--acr:108,196,255;--ac2:#ffb547;--bd:rgba(232,241,250,.1);--bd2:rgba(232,241,250,.2);--on:#06223a;--err:#ff9a9a;--errbg:#3a1822}
|
||||||
@@ -42,7 +43,7 @@ export function renderLoginPage(options: LoginPageOptions): string {
|
|||||||
<span class="f">aws</span> sqs create-queue <span class="o">--queue-name</span> fulfilment-queue
|
<span class="f">aws</span> sqs create-queue <span class="o">--queue-name</span> fulfilment-queue
|
||||||
<span class="f">aws</span> stepfunctions list-state-machines</code></pre></div></section>
|
<span class="f">aws</span> stepfunctions list-state-machines</code></pre></div></section>
|
||||||
<footer class="mono"><div><span><span class="prompt">$</span> TheCloud</span><span>press <kbd>T</kbd> to switch theme</span></div></footer></div></div>
|
<footer class="mono"><div><span><span class="prompt">$</span> TheCloud</span><span>press <kbd>T</kbd> to switch theme</span></div></footer></div></div>
|
||||||
<script nonce="${options.styleNonce}">(function(){var root=document.documentElement,btns=document.querySelectorAll('.tg button'),still=matchMedia('(prefers-reduced-motion: reduce)').matches;function setTheme(t){root.setAttribute('data-theme',t);try{localStorage.setItem('thecloud-theme',t)}catch(e){}btns.forEach(function(b){b.classList.toggle('on',b.dataset.t===t)})}var initial=matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';try{var saved=localStorage.getItem('thecloud-theme');if(saved==='dark'||saved==='light'||saved==='cyber')initial=saved}catch(e){}setTheme(initial);btns.forEach(function(b){b.addEventListener('click',function(){setTheme(b.dataset.t)})});document.addEventListener('keydown',function(e){if((e.key==='t'||e.key==='T')&&!e.metaKey&&!e.ctrlKey&&!e.altKey&&!/INPUT|TEXTAREA/.test(e.target.tagName)){var themes=['dark','light','cyber'];setTheme(themes[(themes.indexOf(root.getAttribute('data-theme'))+1)%themes.length])}});var lines=['hello, engineer.','aws s3 mb s3://my-bucket','your own AWS, on your own hardware.','sfn start-execution --local','welcome to TheCloud.'],el=document.getElementById('tw'),line=0,count=0,deleting=false;if(still){el.textContent=lines[2]}else{(function type(){var word=lines[line];if(deleting){count--;el.textContent=word.slice(0,count);if(count===0){deleting=false;line=(line+1)%lines.length;setTimeout(type,400);return}setTimeout(type,35);return}count++;el.textContent=word.slice(0,count);if(count===word.length){deleting=true;setTimeout(type,2200);return}setTimeout(type,75+Math.random()*50)})()}var hero=document.getElementById('top'),glow=document.getElementById('grid-glow');if(!still){hero.addEventListener('mousemove',function(e){var rect=hero.getBoundingClientRect();glow.style.left=(e.clientX-rect.left)+'px';glow.style.top=(e.clientY-rect.top)+'px';glow.style.opacity='1'});hero.addEventListener('mouseleave',function(){glow.style.opacity='0'})}var bar=document.getElementById('bar');addEventListener('scroll',function(){var height=document.documentElement.scrollHeight-innerHeight;bar.style.width=(height>0?scrollY/height*100:0)+'%'},{passive:true});var form=document.querySelector('[data-sso-form]');if(form)form.addEventListener('submit',function(){var button=form.querySelector('.btn');button.classList.add('is-loading');button.disabled=true;document.getElementById('label').textContent='Redirecting...' });var copy=document.getElementById('cp');if(copy)copy.addEventListener('click',function(){navigator.clipboard.writeText(document.getElementById('code').innerText).then(function(){copy.textContent='copied';copy.classList.add('ok');setTimeout(function(){copy.textContent='copy';copy.classList.remove('ok')},1800)})})})()</script>
|
<script defer src="/login.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
var t = localStorage.getItem("floci-theme");
|
var t = localStorage.getItem("floci-theme");
|
||||||
document.documentElement.setAttribute(
|
document.documentElement.setAttribute(
|
||||||
"data-theme",
|
"data-theme",
|
||||||
t === "light" ? "light" : "dark",
|
t === "light" || t === "cyber" ? t : "dark",
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ export default function App() {
|
|||||||
<Route index element={<Navigate to="/console/aws" replace/>}/>
|
<Route index element={<Navigate to="/console/aws" replace/>}/>
|
||||||
<Route path="/dashboard" element={<Navigate to="/console/aws" replace/>}/>
|
<Route path="/dashboard" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
<Route path="/console" element={<Navigate to="/console/aws" replace/>}/>
|
<Route path="/console" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
|
<Route path="/console/azure" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
|
<Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/>
|
||||||
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
|
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
|
||||||
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||||
|
<Route path="/cloud-explorer/azure/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||||
|
<Route path="/cloud-explorer/gcp/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||||
<Route path="/cloud-explorer/:cloud/:service" element={<CloudExplorerPage/>}/>
|
<Route path="/cloud-explorer/:cloud/:service" element={<CloudExplorerPage/>}/>
|
||||||
<Route path="/cloud-explorer/:cloud/:service/:resourceId/data" element={<DatabaseDataPage/>}/>
|
<Route path="/cloud-explorer/:cloud/:service/:resourceId/data" element={<DatabaseDataPage/>}/>
|
||||||
<Route path="/cloud-explorer/:cloud/nosql/:resourceId/data/items/new" element={<DynamoDbItemEditorPage/>}/>
|
<Route path="/cloud-explorer/:cloud/nosql/:resourceId/data/items/new" element={<DynamoDbItemEditorPage/>}/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
|
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
|
||||||
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun} from 'lucide-react'
|
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react'
|
||||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import theCloud from '../../../../TheCloud.png'
|
import theCloud from '../../../../TheCloud.png'
|
||||||
import {useTheme} from '@/lib/useTheme'
|
import {useTheme} from '@/lib/useTheme'
|
||||||
@@ -157,9 +157,8 @@ function groupByGroup(services: CloudServiceDescriptor[]): Array<[string, CloudS
|
|||||||
export function Layout() {
|
export function Layout() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const activeCloud = activeCloudFromPath(location.pathname)
|
const activeCloud = activeCloudFromPath(location.pathname)
|
||||||
const {theme, toggle} = useTheme()
|
const {theme, setTheme} = useTheme()
|
||||||
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
|
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
|
||||||
const isDark = theme === 'dark'
|
|
||||||
const {data, isError} = useQuery({
|
const {data, isError} = useQuery({
|
||||||
queryKey: ['cloud-status', activeCloud],
|
queryKey: ['cloud-status', activeCloud],
|
||||||
queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
|
queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
|
||||||
@@ -209,15 +208,11 @@ export function Layout() {
|
|||||||
<div className="shell">
|
<div className="shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<TopbarSearch/>
|
<TopbarSearch/>
|
||||||
<button
|
<div className="theme-switcher" role="group" aria-label="Console theme">
|
||||||
className="icon-btn"
|
<button className={`icon-btn ${theme === 'dark' ? 'active' : ''}`} type="button" onClick={() => setTheme('dark')} title="Dark theme" aria-label="Dark theme" aria-pressed={theme === 'dark'}><Moon size={14} aria-hidden="true"/></button>
|
||||||
type="button"
|
<button className={`icon-btn ${theme === 'light' ? 'active' : ''}`} type="button" onClick={() => setTheme('light')} title="Light theme" aria-label="Light theme" aria-pressed={theme === 'light'}><Sun size={14} aria-hidden="true"/></button>
|
||||||
onClick={toggle}
|
<button className={`icon-btn ${theme === 'cyber' ? 'active' : ''}`} type="button" onClick={() => setTheme('cyber')} title="Terminal theme" aria-label="Terminal theme" aria-pressed={theme === 'cyber'}><Terminal size={14} aria-hidden="true"/></button>
|
||||||
title={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
</div>
|
||||||
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
|
||||||
>
|
|
||||||
{isDark ? <Sun size={14} aria-hidden="true"/> : <Moon size={14} aria-hidden="true"/>}
|
|
||||||
</button>
|
|
||||||
<div id="topbar-status" className="topbar-status"/>
|
<div id="topbar-status" className="topbar-status"/>
|
||||||
<AccountSwitcher/>
|
<AccountSwitcher/>
|
||||||
<button className="icon-btn" type="button" title="Sign out" aria-label="Sign out" onClick={() => void signOut()}>
|
<button className="icon-btn" type="button" title="Sign out" aria-label="Sign out" onClick={() => void signOut()}>
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ test('shows an object extension instead of a generic object type', async () => {
|
|||||||
expect(await screen.findByText('JSON file')).toBeInTheDocument()
|
expect(await screen.findByText('JSON file')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('offers a copy action for the AWS bucket name', async () => {
|
||||||
|
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: []})
|
||||||
|
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<StorageObjectBrowser cloud="aws" resource={bucket} runtimeReachable capabilities={[]} onSelectObject={() => {}}/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', {name: 'Copy Bucket name'})).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
test('opens the object action with copy selected', async () => {
|
test('opens the object action with copy selected', async () => {
|
||||||
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: [{
|
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: [{
|
||||||
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
|
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {CapabilitySchema, ObjectActionName} from '@/types/schema'
|
|||||||
import {formatBytes, objectIconKind, objectPreviewKind} from '@/lib/format'
|
import {formatBytes, objectIconKind, objectPreviewKind} from '@/lib/format'
|
||||||
import {ImagePreviewModal} from '@/components/ImagePreviewModal'
|
import {ImagePreviewModal} from '@/components/ImagePreviewModal'
|
||||||
import {ObjectThumbnail} from '@/components/ObjectThumbnail'
|
import {ObjectThumbnail} from '@/components/ObjectThumbnail'
|
||||||
|
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||||
|
|
||||||
interface StorageObjectBrowserProps {
|
interface StorageObjectBrowserProps {
|
||||||
cloud: CloudProvider
|
cloud: CloudProvider
|
||||||
@@ -268,7 +269,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
|||||||
<div className="object-browser-header">
|
<div className="object-browser-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Objects</p>
|
<p className="eyebrow">Objects</p>
|
||||||
<h3>{resource.name}</h3>
|
<h3>{cloud === 'aws' ? <span className="copy-value">{resource.name}<CopyValueButton value={resource.name} label="Bucket name"/></span> : resource.name}</h3>
|
||||||
<div className="object-browser-subtitle">
|
<div className="object-browser-subtitle">
|
||||||
<ObjectBreadcrumb prefix={prefix} onNavigate={navigate}/>
|
<ObjectBreadcrumb prefix={prefix} onNavigate={navigate}/>
|
||||||
<span>{totalFolders} folders, {totalFiles} {objectLabel}</span>
|
<span>{totalFolders} folders, {totalFiles} {objectLabel}</span>
|
||||||
|
|||||||
@@ -218,6 +218,58 @@
|
|||||||
--sidebar-overlay-shadow: 6px 0 28px rgba(38, 48, 82, 0.18);
|
--sidebar-overlay-shadow: 6px 0 28px rgba(38, 48, 82, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="cyber"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--bg: #02060a;
|
||||||
|
--surface: #050d10;
|
||||||
|
--elevated: #071216;
|
||||||
|
--raised: #0a191d;
|
||||||
|
--raised-hover: #10252a;
|
||||||
|
--border: rgba(78, 255, 150, 0.28);
|
||||||
|
--border-faint: rgba(78, 255, 150, 0.16);
|
||||||
|
--border-lighter: rgba(78, 255, 150, 0.08);
|
||||||
|
--border-hover: #ffea00;
|
||||||
|
--active-nav-bg: rgba(78, 255, 150, 0.14);
|
||||||
|
|
||||||
|
--text: #8affb8;
|
||||||
|
--text-bright: #d6ffe6;
|
||||||
|
--text-2: #62d992;
|
||||||
|
--text-3: #2f9a68;
|
||||||
|
--text-muted: var(--text-3);
|
||||||
|
--text-nav: #8affb8;
|
||||||
|
|
||||||
|
--status-ok: #4eff96;
|
||||||
|
--status-warn: #ffea00;
|
||||||
|
--status-error: #ff8da1;
|
||||||
|
|
||||||
|
--accent: #4eff96;
|
||||||
|
--accent-tint: rgba(78, 255, 150, 0.14);
|
||||||
|
--accent-tint-faint: rgba(78, 255, 150, 0.07);
|
||||||
|
--accent-tint-border: rgba(78, 255, 150, 0.34);
|
||||||
|
|
||||||
|
--link: #ffea00;
|
||||||
|
--link-tint: rgba(255, 234, 0, 0.08);
|
||||||
|
--link-tint-hover: rgba(255, 234, 0, 0.14);
|
||||||
|
--link-tint-border: rgba(255, 234, 0, 0.3);
|
||||||
|
|
||||||
|
--hover: rgba(78, 255, 150, 0.06);
|
||||||
|
--hover-md: rgba(78, 255, 150, 0.1);
|
||||||
|
--hover-lg: rgba(78, 255, 150, 0.14);
|
||||||
|
--input-border: rgba(78, 255, 150, 0.42);
|
||||||
|
--input-bg: #030b0d;
|
||||||
|
--connection-bg: #02090c;
|
||||||
|
--kbd-border: rgba(78, 255, 150, 0.35);
|
||||||
|
--scrollbar-thumb: #26784e;
|
||||||
|
--service-card-bg: #050d10;
|
||||||
|
--service-card-hover: #071216;
|
||||||
|
--fn-card-hover: #0a191d;
|
||||||
|
--bulk-bar-bg: #071b13;
|
||||||
|
--bulk-bar-border: #4eff96;
|
||||||
|
--bulk-bar-text: #d6ffe6;
|
||||||
|
--code-bg: #000;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -626,6 +678,26 @@ pre,
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-switcher {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--connection-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switcher .icon-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switcher .icon-btn.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(480px, 100%);
|
width: min(480px, 100%);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
|
|
||||||
type Theme = 'dark' | 'light'
|
export type Theme = 'dark' | 'light' | 'cyber'
|
||||||
|
|
||||||
function getStored(): Theme {
|
function getStored(): Theme {
|
||||||
const v = localStorage.getItem('floci-theme')
|
const v = localStorage.getItem('floci-theme')
|
||||||
return v === 'light' ? 'light' : 'dark'
|
return v === 'light' || v === 'cyber' ? v : 'dark'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
@@ -15,7 +15,5 @@ export function useTheme() {
|
|||||||
localStorage.setItem('floci-theme', theme)
|
localStorage.setItem('floci-theme', theme)
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
|
return {theme, setTheme}
|
||||||
|
|
||||||
return {theme, toggle}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {Cloud} from 'lucide-react'
|
import {Cloud} from 'lucide-react'
|
||||||
import {Navigate, useNavigate, useParams, useSearchParams} from 'react-router-dom'
|
import {Navigate, useNavigate, useParams, useSearchParams} from 'react-router-dom'
|
||||||
import {CloudSelector} from '@/components/CloudSelector'
|
|
||||||
import {
|
import {
|
||||||
ProviderBanner,
|
ProviderBanner,
|
||||||
RuntimeFlow,
|
RuntimeFlow,
|
||||||
@@ -39,16 +38,6 @@ export function CloudConsoleHomePage() {
|
|||||||
<p className="muted">Cloud-aware local runtime overview</p>
|
<p className="muted">Cloud-aware local runtime overview</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="cloud-header-selectors">
|
|
||||||
<label>
|
|
||||||
<span>Cloud</span>
|
|
||||||
<CloudSelector
|
|
||||||
clouds={data.cloudsQuery.data ?? []}
|
|
||||||
selected={cloud}
|
|
||||||
onSelect={(nextCloud) => navigate(`/console/${nextCloud}`)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="content cloud-console-home">
|
<div className="content cloud-console-home">
|
||||||
|
|||||||
Reference in New Issue
Block a user