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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/destroy-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/setup-control-plane.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/spin-up.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/teardown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down
26 changes: 14 additions & 12 deletions control-plane/functions/api/send-credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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}`);

Expand Down Expand Up @@ -129,15 +132,14 @@ export async function onRequestPost(context) {
</div>
`;

// Send email via Resend (User as primary, Admin + extra users in CC)
const emailPayload = {
from: `Nexus-Stack <nexus@${fromDomain}>`,
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', {
Expand Down
3 changes: 2 additions & 1 deletion docs/admin-guides/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 4 additions & 3 deletions tofu/control-plane/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions tofu/control-plane/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions tofu/stack/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
)))
}

Expand Down
6 changes: 6 additions & 0 deletions tofu/stack/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down