diff --git a/.github/workflows/destroy-all.yml b/.github/workflows/destroy-all.yml index 366c1058..b262ac68 100644 --- a/.github/workflows/destroy-all.yml +++ b/.github/workflows/destroy-all.yml @@ -115,6 +115,7 @@ jobs: subdomain_separator = "${{ secrets.SUBDOMAIN_SEPARATOR || '.' }}" admin_email = "${{ secrets.TF_VAR_admin_email }}" user_email = "${{ secrets.TF_VAR_user_email }}" + guest_emails = "${{ secrets.TF_VAR_guest_emails }}" admin_username = "admin" github_owner = "${{ github.repository_owner }}" github_repo = "${{ github.event.repository.name }}" @@ -146,6 +147,7 @@ jobs: subdomain_separator = "${{ secrets.SUBDOMAIN_SEPARATOR || '.' }}" admin_email = "${{ secrets.TF_VAR_admin_email }}" user_email = "${{ secrets.TF_VAR_user_email }}" + guest_emails = "${{ secrets.TF_VAR_guest_emails }}" github_owner = "${{ github.repository_owner }}" github_repo = "${{ github.event.repository.name }}" EOF diff --git a/.github/workflows/setup-control-plane.yaml b/.github/workflows/setup-control-plane.yaml index 606fa6f3..21415a94 100644 --- a/.github/workflows/setup-control-plane.yaml +++ b/.github/workflows/setup-control-plane.yaml @@ -170,6 +170,7 @@ jobs: subdomain_separator = "${{ secrets.SUBDOMAIN_SEPARATOR || '.' }}" admin_email = "${{ secrets.TF_VAR_admin_email }}" user_email = "${{ secrets.TF_VAR_user_email }}" + guest_emails = "${{ secrets.TF_VAR_guest_emails }}" github_owner = "${{ github.repository_owner }}" github_repo = "${{ github.event.repository.name }}" allow_disable_auto_shutdown = ${{ vars.ALLOW_DISABLE_AUTO_SHUTDOWN == 'true' }} diff --git a/.github/workflows/spin-up.yml b/.github/workflows/spin-up.yml index 73828769..741c9fe9 100644 --- a/.github/workflows/spin-up.yml +++ b/.github/workflows/spin-up.yml @@ -279,6 +279,7 @@ jobs: domain = "${{ secrets.DOMAIN }}" admin_email = "${{ secrets.TF_VAR_admin_email }}" user_email = "${{ secrets.TF_VAR_user_email }}" + guest_emails = "${{ secrets.TF_VAR_guest_emails }}" admin_username = "admin" subdomain_separator = "${{ secrets.SUBDOMAIN_SEPARATOR || '.' }}" github_owner = "${{ github.repository_owner }}" diff --git a/.github/workflows/teardown.yml b/.github/workflows/teardown.yml index 9a8df2ab..42360dee 100644 --- a/.github/workflows/teardown.yml +++ b/.github/workflows/teardown.yml @@ -164,6 +164,7 @@ jobs: subdomain_separator = "${{ secrets.SUBDOMAIN_SEPARATOR || '.' }}" admin_email = "${{ secrets.TF_VAR_admin_email }}" user_email = "${{ secrets.TF_VAR_user_email }}" + guest_emails = "${{ secrets.TF_VAR_guest_emails }}" admin_username = "admin" github_owner = "${{ github.repository_owner }}" github_repo = "${{ github.event.repository.name }}" diff --git a/control-plane/functions/api/send-credentials.js b/control-plane/functions/api/send-credentials.js index 95ce5b07..94877967 100644 --- a/control-plane/functions/api/send-credentials.js +++ b/control-plane/functions/api/send-credentials.js @@ -18,7 +18,7 @@ const BRACKETED_EMAIL_RE = /^\S[^<>]*<[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+>$/; const isValidResendEmail = (e) => PLAIN_EMAIL_RE.test(e) || BRACKETED_EMAIL_RE.test(e); export async function onRequestPost(context) { - const { env } = context; + const { env, request } = context; // Validate environment variables const requiredEnv = ['RESEND_API_KEY', 'ADMIN_EMAIL', 'DOMAIN']; @@ -60,18 +60,21 @@ export async function onRequestPost(context) { const fromDomain = env.BASE_DOMAIN || domain; const adminEmail = env.ADMIN_EMAIL; - // USER_EMAIL may be a single address or a comma-separated list - // (e.g. when multiple admin emails are piped through from the admin panel). - // Split + trim + validate against the Resend-accepted email regex - // (hoisted to module scope above). + // TO is the Access-authenticated caller, so each user gets their own copy. + // Header is missing only in preview / misconfigured Access — fall back then. + const accessEmailHeader = (request.headers.get('CF-Access-Authenticated-User-Email') || '').trim(); + const accessEmail = isValidResendEmail(accessEmailHeader) ? accessEmailHeader : null; + + // USER_EMAIL may be a single address or a comma-separated list. const userEmails = (env.USER_EMAIL || '') .split(',') .map((e) => e.trim()) .filter(isValidResendEmail); - const primaryUserEmail = userEmails[0] || null; - const extraUserEmails = userEmails.slice(1); - // Back-compat: keep `userEmail` as the primary for downstream logic. - const userEmail = primaryUserEmail; + const fallbackUserEmail = userEmails[0] || null; + const userEmail = accessEmail || fallbackUserEmail; + if (!accessEmail) { + console.warn('send-credentials: CF-Access-Authenticated-User-Email missing or invalid, falling back to USER_EMAIL[0]'); + } const infisicalUrl = safeHttpsUrl(env.INFISICAL_URL, `https://infisical.${domain}`); const controlPlaneUrl = safeHttpsUrl(env.CONTROL_PLANE_URL, `https://control.${domain}`); @@ -129,15 +132,14 @@ export async function onRequestPost(context) { `; - // Send email via Resend (User as primary, Admin + extra users in CC) const emailPayload = { from: `Nexus-Stack `, to: userEmail ? [userEmail] : [adminEmail], subject: '🔐 Nexus-Stack Credentials', html: emailHTML }; - if (userEmail) { - emailPayload.cc = [adminEmail, ...extraUserEmails]; + if (userEmail && userEmail !== adminEmail) { + emailPayload.cc = [adminEmail]; } const resendResponse = await fetchWithTimeout('https://api.resend.com/emails', { diff --git a/docs/admin-guides/setup-guide.md b/docs/admin-guides/setup-guide.md index a69439ed..fdca3783 100644 --- a/docs/admin-guides/setup-guide.md +++ b/docs/admin-guides/setup-guide.md @@ -133,7 +133,8 @@ Add these secrets to your GitHub repository: | Secret Name | Description | |-------------|-------------| | `GH_SECRETS_TOKEN` | GitHub PAT for R2 auto-save and Cloudflare runtime (see below) | -| `TF_VAR_user_email` | User - all services except SSH | +| `TF_VAR_user_email` | User - all services except SSH (also receives notifications) | +| `TF_VAR_guest_emails` | Comma-separated guests - Access whitelist only, no notifications | | `RESEND_API_KEY` | Email notifications via Resend | | `DOCKERHUB_USERNAME` | Docker Hub username (higher pull limits) | | `DOCKERHUB_TOKEN` | Docker Hub access token | diff --git a/tofu/control-plane/main.tf b/tofu/control-plane/main.tf index 6db2cbc1..66b01bd7 100644 --- a/tofu/control-plane/main.tf +++ b/tofu/control-plane/main.tf @@ -12,11 +12,12 @@ locals { # Resource prefix derived from domain (e.g., "example.com" → "nexus-example-com") resource_prefix = "nexus-${replace(var.domain, ".", "-")}" - # List of emails allowed to access control plane (admin + optional user) - # user_email may be comma-separated, so split and trim into individual entries + # List of emails allowed to access control plane (admin + optional user + optional guests) + # user_email and guest_emails may be comma-separated, so split and trim into individual entries allowed_emails = distinct(compact(concat( [trimspace(var.admin_email)], - [for email in split(",", var.user_email) : trimspace(email)] + [for email in split(",", var.user_email) : trimspace(email)], + [for email in split(",", var.guest_emails) : trimspace(email)] ))) # Control Plane URLs. Built from the base domain and the subdomain separator diff --git a/tofu/control-plane/variables.tf b/tofu/control-plane/variables.tf index 49a4b9b5..a207568e 100644 --- a/tofu/control-plane/variables.tf +++ b/tofu/control-plane/variables.tf @@ -60,6 +60,12 @@ variable "user_email" { default = "" } +variable "guest_emails" { + description = "Comma-separated guest emails for Cloudflare Access (no SSH, no notifications). Optional." + type = string + default = "" +} + variable "server_type" { description = "Hetzner server type (passed to Control Plane for display)" type = string diff --git a/tofu/stack/main.tf b/tofu/stack/main.tf index 05839305..61219088 100644 --- a/tofu/stack/main.tf +++ b/tofu/stack/main.tf @@ -7,11 +7,12 @@ locals { # This ensures unique resource names when multiple users deploy Nexus-Stack resource_prefix = "nexus-${replace(var.domain, ".", "-")}" - # List of emails allowed to access services (admin + optional user) - # user_email may be comma-separated, so split and trim into individual entries + # List of emails allowed to access services (admin + optional user + optional guests) + # user_email and guest_emails may be comma-separated, so split and trim into individual entries allowed_emails = distinct(compact(concat( [trimspace(var.admin_email)], - [for email in split(",", var.user_email) : trimspace(email)] + [for email in split(",", var.user_email) : trimspace(email)], + [for email in split(",", var.guest_emails) : trimspace(email)] ))) } diff --git a/tofu/stack/variables.tf b/tofu/stack/variables.tf index deb3f5fd..687bbd2a 100644 --- a/tofu/stack/variables.tf +++ b/tofu/stack/variables.tf @@ -150,6 +150,12 @@ variable "user_email" { default = "" } +variable "guest_emails" { + description = "Comma-separated guest emails for Cloudflare Access (no SSH, no notifications). Optional." + type = string + default = "" +} + variable "admin_username" { description = "Admin username for services like Portainer, Uptime Kuma (default: nexus)" type = string