Skip to content

Commit 4afdfaf

Browse files
feat: automatic cloudflare proxy subdomain provisioning
1 parent f38edb2 commit 4afdfaf

5 files changed

Lines changed: 159 additions & 4 deletions

File tree

app/actions/cloudflare.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use server'
2+
3+
export async function createCloudflareSubdomain(slug: string) {
4+
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
5+
const apiToken = process.env.CF_DNS_API_TOKEN;
6+
const serverIp = process.env.SERVER_IP;
7+
const platformDomain = process.env.NEXT_PUBLIC_PLATFORM_DOMAIN;
8+
9+
if (!zoneId || !apiToken || !serverIp || !platformDomain) {
10+
console.error("Missing Cloudflare or server environment variables");
11+
// If not configured, we just return safely, assuming local dev or wildcards
12+
return { success: false, reason: "Missing ENV vars" };
13+
}
14+
15+
const fullRecordName = `${slug}.${platformDomain}`;
16+
17+
console.log(`Creating proxied Cloudflare DNS record for: ${fullRecordName}`);
18+
19+
try {
20+
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
21+
method: 'POST',
22+
headers: {
23+
'Content-Type': 'application/json',
24+
'Authorization': `Bearer ${apiToken}`
25+
},
26+
body: JSON.stringify({
27+
type: 'A',
28+
name: fullRecordName, // Must use the full domain like myschool.preciopana.com
29+
content: serverIp, // Netcup IP
30+
ttl: 1, // 1 = Auto
31+
proxied: true // Enables the Orange Cloud proxy (DDoS, SSL, WAF)
32+
})
33+
});
34+
35+
if (!response.ok) {
36+
const errorData = await response.json();
37+
console.error("Cloudflare API Error:", JSON.stringify(errorData, null, 2));
38+
39+
// If the record already exists (error 81057), that's fine, we can ignore it
40+
const alreadyExists = errorData.errors?.some((e: any) => e.code === 81057);
41+
if (alreadyExists) {
42+
return { success: true, reason: "Already exists" };
43+
}
44+
45+
throw new Error(`Cloudflare API failed: ${response.statusText}`);
46+
}
47+
48+
const data = await response.json();
49+
return { success: true, data };
50+
} catch (error) {
51+
console.error("Error creating subdomain:", error);
52+
throw error;
53+
}
54+
}

components/tenant/create-school-form.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState } from 'react'
44
import { useRouter } from 'next/navigation'
55
import { createClient } from '@/lib/supabase/client'
6+
import { createCloudflareSubdomain } from '@/app/actions/cloudflare'
67
import { Button } from '@/components/ui/button'
78
import { Input } from '@/components/ui/input'
89
import { Label } from '@/components/ui/label'
@@ -61,13 +62,26 @@ export function CreateSchoolForm({ userId }: { userId: string }) {
6162
// Refresh session so the JWT picks up the new tenant_id/tenant_role
6263
await supabase.auth.refreshSession()
6364

65+
// Provision Cloudflare Secure Proxy limit
66+
try {
67+
await createCloudflareSubdomain(slug.trim())
68+
} catch (cfError) {
69+
console.error("Cloudflare integration error:", cfError)
70+
// We don't block the user, but it might take a few seconds to resolve
71+
}
72+
6473
toast.success('School created successfully!')
6574

66-
// Redirect to subdomain
75+
// Redirect to subdomain with a slight delay if on production to allow DNS propagation
6776
const platformDomain = process.env.NEXT_PUBLIC_PLATFORM_DOMAIN
6877
if (platformDomain && platformDomain !== 'localhost') {
6978
const protocol = window.location.protocol // Preserves http: or https:
70-
window.location.href = `${protocol}//${slug}.${platformDomain}/onboarding`
79+
80+
// Small visual delay so Cloudflare has time to proxy
81+
toast.loading("Setting up secure connection... redirecting in 3s")
82+
setTimeout(() => {
83+
window.location.href = `${protocol}//${slug}.${platformDomain}/onboarding`
84+
}, 3500)
7185
} else {
7286
router.push('/onboarding')
7387
}

docs/CLOUDFLARE_WILDCARD_SETUP.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Deployment & Firewall Notes: Cloudflare Wildcards & Security
2+
3+
This document captures the findings and architectural decisions regarding DNS, SSL (Let's Encrypt), Firewalls, and Cloudflare integration when hosting the LMS on Dokploy/Netcup.
4+
5+
## The Problem: Traefik Timeout Errors
6+
When attempting to setup wildcard subdomains (`*.preciopana.com`) with Let's Encrypt on Traefik, we encountered two timeout errors:
7+
1. **HTTP Challenge Timeout (Port 80):** Let's Encrypt could not reach the server because the Netcup hardware firewall restricted ports 80 and 443 to *only* Cloudflare IPs. Since the wildcard record in Cloudflare was set to DNS Only (Grey Cloud), Let's Encrypt tried to connect directly (not via Cloudflare) and was blocked by Netcup.
8+
2. **DNS Challenge Timeout (Port 53):** Traefik could not reach Cloudflare's DNS (1.1.1.1:53) because UFW (Ubuntu's internal firewall) blocks Docker's outbound requests by default. (Since Netcup provides a hardware firewall, UFW is redundant and should be disabled).
9+
10+
## The Cloudflare Limitation
11+
On Cloudflare Free/Pro/Business tiers, **wildcard DNS records (`*`) cannot be proxied (Orange Cloud)**. They must be DNS Only (Grey Cloud). This means wildcard traffic hits the server directly. If the server firewall only allows Cloudflare IPs, the wildcard traffic is completely blocked.
12+
13+
## The Solution: Option B (Strict Cloudflare Proxying)
14+
To maintain maximum security (keeping the Netcup firewall restricted *only* to Cloudflare IPs and keeping all traffic Proxied/Orange Cloud), we cannot use a blanket `*` wildcard record.
15+
16+
Instead, we must dynamically create an explicit `A` record for every new school via the Cloudflare API when a user finishes the `/create-school` flow.
17+
18+
### Implementation Plan for Option B (To be done in the future)
19+
20+
#### 1. Environment Variables
21+
Add these to `.env.local` and Dokploy environment variables:
22+
```env
23+
CLOUDFLARE_ZONE_ID=your_zone_id_here
24+
SERVER_IP=152.53.160.37
25+
# CF_DNS_API_TOKEN is already present
26+
```
27+
28+
#### 2. Server Action (e.g., `app/actions/cloudflare.ts`)
29+
```typescript
30+
'use server'
31+
32+
export async function createCloudflareSubdomain(slug: string) {
33+
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
34+
const apiToken = process.env.CF_DNS_API_TOKEN;
35+
const serverIp = process.env.SERVER_IP;
36+
37+
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
38+
method: 'POST',
39+
headers: {
40+
'Content-Type': 'application/json',
41+
'Authorization': `Bearer ${apiToken}`
42+
},
43+
body: JSON.stringify({
44+
type: 'A',
45+
name: slug, // e.g., "myschool"
46+
content: serverIp, // Netcup IP
47+
ttl: 1, // Auto TTL
48+
proxied: true // Enables Cloudflare Proxy (Orange Cloud)
49+
})
50+
});
51+
52+
if (!response.ok) {
53+
const errorData = await response.json();
54+
console.error("Cloudflare Error:", errorData);
55+
throw new Error('Failed to create secure subdomain');
56+
}
57+
58+
return await response.json();
59+
}
60+
```
61+
62+
#### 3. Integration in `/create-school` Flow
63+
In `components/tenant/create-school-form.tsx`, call the new action right after the tenant gets inserted into the database:
64+
```typescript
65+
import { createCloudflareSubdomain } from "@/app/actions/cloudflare";
66+
67+
// Inside form submit handler:
68+
try {
69+
// 1. Create school in Supabase...
70+
71+
// 2. Provision Cloudflare subdomain dynamically
72+
await createCloudflareSubdomain(data.slug);
73+
74+
// 3. Show a loading state (e.g., waiting 5-10s for DNS propagation)
75+
76+
// 4. Redirect
77+
window.location.href = `https://${data.slug}.preciopana.com`;
78+
} catch (error) {
79+
toast.error("Error creating school.");
80+
}
81+
```
82+
83+
**Next Steps for Migration:**
84+
- Remove the `*` record from Cloudflare.
85+
- Keep Netcup firewall restricted to Cloudflare IPs.
86+
- Implement the Cloudflare API integration.
87+
- Ensure Dokploy/Traefik expects specific subdomains or gracefully routes them. Since Cloudflare manages the SSL Edge Certificates for proxied records, Traefik doesn't strictly need to do ACME TLS challenges for them as long as Cloudflare communicates with Traefik over HTTP or uses a Cloudflare Origin Cert.

next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/types/routes.d.ts";
3+
import "./.next/dev/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

0 commit comments

Comments
 (0)