@stacksjs/ts-cloud 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/cli.js +1 -1
- package/package.json +18 -16
- package/src/aws/acm.ts +768 -0
- package/src/aws/application-autoscaling.ts +845 -0
- package/src/aws/bedrock.ts +4074 -0
- package/src/aws/client.ts +891 -0
- package/src/aws/cloudformation.ts +896 -0
- package/src/aws/cloudfront.ts +1531 -0
- package/src/aws/cloudwatch-logs.ts +154 -0
- package/src/aws/comprehend.ts +839 -0
- package/src/aws/connect.ts +1056 -0
- package/src/aws/deploy-imap.ts +384 -0
- package/src/aws/dynamodb.ts +340 -0
- package/src/aws/ec2.ts +1385 -0
- package/src/aws/ecr.ts +621 -0
- package/src/aws/ecs.ts +615 -0
- package/src/aws/elasticache.ts +301 -0
- package/src/aws/elbv2.ts +942 -0
- package/src/aws/email.ts +928 -0
- package/src/aws/eventbridge.ts +248 -0
- package/src/aws/iam.ts +1689 -0
- package/src/aws/imap-server.ts +2100 -0
- package/src/aws/index.ts +213 -0
- package/src/aws/kendra.ts +1097 -0
- package/src/aws/lambda.ts +786 -0
- package/src/aws/opensearch.ts +158 -0
- package/src/aws/personalize.ts +977 -0
- package/src/aws/polly.ts +559 -0
- package/src/aws/rds.ts +888 -0
- package/src/aws/rekognition.ts +846 -0
- package/src/aws/route53-domains.ts +359 -0
- package/src/aws/route53.ts +1046 -0
- package/src/aws/s3.ts +2334 -0
- package/src/aws/scheduler.ts +571 -0
- package/src/aws/secrets-manager.ts +769 -0
- package/src/aws/ses.ts +1081 -0
- package/src/aws/setup-phone.ts +104 -0
- package/src/aws/setup-sms.ts +580 -0
- package/src/aws/sms.ts +1735 -0
- package/src/aws/smtp-server.ts +531 -0
- package/src/aws/sns.ts +758 -0
- package/src/aws/sqs.ts +382 -0
- package/src/aws/ssm.ts +807 -0
- package/src/aws/sts.ts +92 -0
- package/src/aws/support.ts +391 -0
- package/src/aws/test-imap.ts +86 -0
- package/src/aws/textract.ts +780 -0
- package/src/aws/transcribe.ts +108 -0
- package/src/aws/translate.ts +641 -0
- package/src/aws/voice.ts +1379 -0
- package/src/config.ts +35 -0
- package/src/deploy/index.ts +7 -0
- package/src/deploy/static-site-external-dns.ts +945 -0
- package/src/deploy/static-site.ts +1175 -0
- package/src/dns/cloudflare.ts +548 -0
- package/src/dns/godaddy.ts +412 -0
- package/src/dns/index.ts +205 -0
- package/src/dns/porkbun.ts +362 -0
- package/src/dns/route53-adapter.ts +414 -0
- package/src/dns/types.ts +119 -0
- package/src/dns/validator.ts +369 -0
- package/src/generators/index.ts +5 -0
- package/src/generators/infrastructure.ts +1660 -0
- package/src/index.ts +163 -0
- package/src/push/apns.ts +452 -0
- package/src/push/fcm.ts +506 -0
- package/src/push/index.ts +58 -0
- package/src/security/pre-deploy-scanner.ts +655 -0
- package/src/ssl/acme-client.ts +478 -0
- package/src/ssl/index.ts +7 -0
- package/src/ssl/letsencrypt.ts +747 -0
- package/src/types.ts +2 -0
- package/src/utils/cli.ts +398 -0
- package/src/validation/index.ts +5 -0
- package/src/validation/template.ts +405 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Let's Encrypt Integration for Stacks
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for obtaining and managing Let's Encrypt certificates.
|
|
5
|
+
* Supports both HTTP-01 and DNS-01 challenges with multiple DNS providers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DnsProvider, DnsProviderConfig } from '../dns/types'
|
|
9
|
+
import { createDnsProvider } from '../dns'
|
|
10
|
+
import { Route53Client } from '../aws/route53'
|
|
11
|
+
|
|
12
|
+
export interface LetsEncryptConfig {
|
|
13
|
+
/**
|
|
14
|
+
* Domain names to obtain certificate for
|
|
15
|
+
*/
|
|
16
|
+
domains: string[]
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Email for Let's Encrypt notifications
|
|
20
|
+
*/
|
|
21
|
+
email: string
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Use staging server for testing
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
staging?: boolean
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Challenge type
|
|
31
|
+
* - 'http-01': Serve challenge file via HTTP (requires port 80)
|
|
32
|
+
* - 'dns-01': Add TXT record to DNS (works behind load balancers)
|
|
33
|
+
* @default 'http-01'
|
|
34
|
+
*/
|
|
35
|
+
challengeType?: 'http-01' | 'dns-01'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Route53 hosted zone ID (required for dns-01 challenge with Route53)
|
|
39
|
+
* @deprecated Use dnsProvider config instead
|
|
40
|
+
*/
|
|
41
|
+
hostedZoneId?: string
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* DNS provider configuration for dns-01 challenge
|
|
45
|
+
* Supports: route53, porkbun, godaddy
|
|
46
|
+
*/
|
|
47
|
+
dnsProvider?: DnsProviderConfig
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Certificate storage path
|
|
51
|
+
* @default '/etc/letsencrypt/live'
|
|
52
|
+
*/
|
|
53
|
+
certPath?: string
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Auto-renew certificates
|
|
57
|
+
* @default true
|
|
58
|
+
*/
|
|
59
|
+
autoRenew?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* DNS-01 challenge configuration for programmatic use
|
|
64
|
+
*/
|
|
65
|
+
export interface Dns01ChallengeConfig {
|
|
66
|
+
domain: string
|
|
67
|
+
challengeValue: string
|
|
68
|
+
/**
|
|
69
|
+
* Route53 hosted zone ID (legacy, use dnsProvider instead)
|
|
70
|
+
* @deprecated Use dnsProvider config instead
|
|
71
|
+
*/
|
|
72
|
+
hostedZoneId?: string
|
|
73
|
+
/**
|
|
74
|
+
* DNS provider configuration
|
|
75
|
+
*/
|
|
76
|
+
dnsProvider?: DnsProviderConfig
|
|
77
|
+
/**
|
|
78
|
+
* AWS region (only for Route53)
|
|
79
|
+
*/
|
|
80
|
+
region?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Generate UserData script for Let's Encrypt certificate setup on EC2
|
|
85
|
+
* This creates a complete setup that handles certificate acquisition and renewal
|
|
86
|
+
*/
|
|
87
|
+
export function generateLetsEncryptUserData(config: LetsEncryptConfig): string {
|
|
88
|
+
const {
|
|
89
|
+
domains,
|
|
90
|
+
email,
|
|
91
|
+
staging = false,
|
|
92
|
+
challengeType = 'http-01',
|
|
93
|
+
certPath = '/etc/letsencrypt/live',
|
|
94
|
+
autoRenew = true,
|
|
95
|
+
} = config
|
|
96
|
+
|
|
97
|
+
const primaryDomain = domains[0]
|
|
98
|
+
const domainFlags = domains.map(d => `-d ${d}`).join(' ')
|
|
99
|
+
const stagingFlag = staging ? '--staging' : ''
|
|
100
|
+
|
|
101
|
+
if (challengeType === 'http-01') {
|
|
102
|
+
return generateHttp01UserData({
|
|
103
|
+
domains,
|
|
104
|
+
email,
|
|
105
|
+
staging,
|
|
106
|
+
certPath,
|
|
107
|
+
autoRenew,
|
|
108
|
+
domainFlags,
|
|
109
|
+
stagingFlag,
|
|
110
|
+
primaryDomain,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
// Determine DNS provider type
|
|
115
|
+
const dnsProviderType = config.dnsProvider?.provider || (config.hostedZoneId ? 'route53' : undefined)
|
|
116
|
+
|
|
117
|
+
return generateDns01UserData({
|
|
118
|
+
domains,
|
|
119
|
+
email,
|
|
120
|
+
staging,
|
|
121
|
+
certPath,
|
|
122
|
+
autoRenew,
|
|
123
|
+
domainFlags,
|
|
124
|
+
stagingFlag,
|
|
125
|
+
primaryDomain,
|
|
126
|
+
hostedZoneId: config.hostedZoneId,
|
|
127
|
+
dnsProvider: config.dnsProvider,
|
|
128
|
+
dnsProviderType,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface UserDataParams {
|
|
134
|
+
domains: string[]
|
|
135
|
+
email: string
|
|
136
|
+
staging: boolean
|
|
137
|
+
certPath: string
|
|
138
|
+
autoRenew: boolean
|
|
139
|
+
domainFlags: string
|
|
140
|
+
stagingFlag: string
|
|
141
|
+
primaryDomain: string
|
|
142
|
+
hostedZoneId?: string
|
|
143
|
+
dnsProvider?: DnsProviderConfig
|
|
144
|
+
dnsProviderType?: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Generate UserData for HTTP-01 challenge
|
|
149
|
+
*/
|
|
150
|
+
function generateHttp01UserData(params: UserDataParams): string {
|
|
151
|
+
const { email, certPath, autoRenew, domainFlags, stagingFlag, primaryDomain } = params
|
|
152
|
+
|
|
153
|
+
return `
|
|
154
|
+
# ==========================================
|
|
155
|
+
# Let's Encrypt Certificate Setup (HTTP-01)
|
|
156
|
+
# ==========================================
|
|
157
|
+
|
|
158
|
+
# Install certbot
|
|
159
|
+
dnf install -y certbot
|
|
160
|
+
|
|
161
|
+
# Stop any service using port 80 temporarily
|
|
162
|
+
systemctl stop stacks 2>/dev/null || true
|
|
163
|
+
|
|
164
|
+
# Obtain certificate using standalone mode
|
|
165
|
+
certbot certonly \\
|
|
166
|
+
--standalone \\
|
|
167
|
+
--non-interactive \\
|
|
168
|
+
--agree-tos \\
|
|
169
|
+
--email ${email} \\
|
|
170
|
+
${stagingFlag} \\
|
|
171
|
+
${domainFlags}
|
|
172
|
+
|
|
173
|
+
# Check if certificate was obtained
|
|
174
|
+
if [ -f "${certPath}/${primaryDomain}/fullchain.pem" ]; then
|
|
175
|
+
echo "Certificate obtained successfully!"
|
|
176
|
+
|
|
177
|
+
# Create symlinks for easier access
|
|
178
|
+
mkdir -p /etc/ssl/stacks
|
|
179
|
+
ln -sf ${certPath}/${primaryDomain}/fullchain.pem /etc/ssl/stacks/fullchain.pem
|
|
180
|
+
ln -sf ${certPath}/${primaryDomain}/privkey.pem /etc/ssl/stacks/privkey.pem
|
|
181
|
+
ln -sf ${certPath}/${primaryDomain}/cert.pem /etc/ssl/stacks/cert.pem
|
|
182
|
+
ln -sf ${certPath}/${primaryDomain}/chain.pem /etc/ssl/stacks/chain.pem
|
|
183
|
+
else
|
|
184
|
+
echo "Failed to obtain certificate!"
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
${autoRenew ? generateRenewalSetup(primaryDomain) : '# Auto-renewal disabled'}
|
|
188
|
+
|
|
189
|
+
# Restart the application
|
|
190
|
+
systemctl start stacks
|
|
191
|
+
`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Generate UserData for DNS-01 challenge
|
|
196
|
+
* Supports Route53 (via certbot plugin) and manual mode for external DNS providers
|
|
197
|
+
*/
|
|
198
|
+
function generateDns01UserData(params: UserDataParams): string {
|
|
199
|
+
const {
|
|
200
|
+
email,
|
|
201
|
+
certPath,
|
|
202
|
+
autoRenew,
|
|
203
|
+
domainFlags,
|
|
204
|
+
stagingFlag,
|
|
205
|
+
primaryDomain,
|
|
206
|
+
hostedZoneId,
|
|
207
|
+
dnsProvider,
|
|
208
|
+
dnsProviderType,
|
|
209
|
+
} = params
|
|
210
|
+
|
|
211
|
+
// Route53 uses certbot's native plugin
|
|
212
|
+
if (dnsProviderType === 'route53' || hostedZoneId) {
|
|
213
|
+
if (!hostedZoneId) {
|
|
214
|
+
throw new Error('hostedZoneId is required for DNS-01 challenge with Route53')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return `
|
|
218
|
+
# ==========================================
|
|
219
|
+
# Let's Encrypt Certificate Setup (DNS-01 via Route53)
|
|
220
|
+
# ==========================================
|
|
221
|
+
|
|
222
|
+
# Install certbot with Route53 plugin
|
|
223
|
+
dnf install -y certbot python3-certbot-dns-route53
|
|
224
|
+
|
|
225
|
+
# Obtain certificate using DNS-01 challenge with Route53
|
|
226
|
+
certbot certonly \\
|
|
227
|
+
--dns-route53 \\
|
|
228
|
+
--non-interactive \\
|
|
229
|
+
--agree-tos \\
|
|
230
|
+
--email ${email} \\
|
|
231
|
+
${stagingFlag} \\
|
|
232
|
+
${domainFlags}
|
|
233
|
+
|
|
234
|
+
# Check if certificate was obtained
|
|
235
|
+
if [ -f "${certPath}/${primaryDomain}/fullchain.pem" ]; then
|
|
236
|
+
echo "Certificate obtained successfully!"
|
|
237
|
+
|
|
238
|
+
# Create symlinks for easier access
|
|
239
|
+
mkdir -p /etc/ssl/stacks
|
|
240
|
+
ln -sf ${certPath}/${primaryDomain}/fullchain.pem /etc/ssl/stacks/fullchain.pem
|
|
241
|
+
ln -sf ${certPath}/${primaryDomain}/privkey.pem /etc/ssl/stacks/privkey.pem
|
|
242
|
+
ln -sf ${certPath}/${primaryDomain}/cert.pem /etc/ssl/stacks/cert.pem
|
|
243
|
+
ln -sf ${certPath}/${primaryDomain}/chain.pem /etc/ssl/stacks/chain.pem
|
|
244
|
+
else
|
|
245
|
+
echo "Failed to obtain certificate!"
|
|
246
|
+
fi
|
|
247
|
+
|
|
248
|
+
${autoRenew ? generateRenewalSetup(primaryDomain) : '# Auto-renewal disabled'}
|
|
249
|
+
`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// For external DNS providers (Porkbun, GoDaddy), use manual mode with hooks
|
|
253
|
+
// The DNS records need to be managed via the API
|
|
254
|
+
if (!dnsProvider) {
|
|
255
|
+
throw new Error('dnsProvider configuration is required for DNS-01 challenge with external DNS providers')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const providerName = dnsProvider.provider.toUpperCase()
|
|
259
|
+
const envVars = generateDnsProviderEnvVars(dnsProvider)
|
|
260
|
+
|
|
261
|
+
return `
|
|
262
|
+
# ==========================================
|
|
263
|
+
# Let's Encrypt Certificate Setup (DNS-01 via ${providerName})
|
|
264
|
+
# ==========================================
|
|
265
|
+
|
|
266
|
+
# Install certbot
|
|
267
|
+
dnf install -y certbot jq curl
|
|
268
|
+
|
|
269
|
+
${envVars}
|
|
270
|
+
|
|
271
|
+
# Create DNS challenge hook scripts directory
|
|
272
|
+
mkdir -p /etc/letsencrypt/hooks
|
|
273
|
+
|
|
274
|
+
# Create authenticator hook for ${providerName}
|
|
275
|
+
cat > /etc/letsencrypt/hooks/auth-hook.sh << 'AUTHHOOK'
|
|
276
|
+
#!/bin/bash
|
|
277
|
+
# DNS-01 authenticator hook for ${providerName}
|
|
278
|
+
# This creates the TXT record for ACME challenge
|
|
279
|
+
|
|
280
|
+
DOMAIN="\$CERTBOT_DOMAIN"
|
|
281
|
+
VALIDATION="\$CERTBOT_VALIDATION"
|
|
282
|
+
RECORD_NAME="_acme-challenge.\$DOMAIN"
|
|
283
|
+
|
|
284
|
+
${generateDnsCreateRecordScript(dnsProvider)}
|
|
285
|
+
|
|
286
|
+
# Wait for DNS propagation
|
|
287
|
+
echo "Waiting 60 seconds for DNS propagation..."
|
|
288
|
+
sleep 60
|
|
289
|
+
AUTHHOOK
|
|
290
|
+
|
|
291
|
+
chmod +x /etc/letsencrypt/hooks/auth-hook.sh
|
|
292
|
+
|
|
293
|
+
# Create cleanup hook for ${providerName}
|
|
294
|
+
cat > /etc/letsencrypt/hooks/cleanup-hook.sh << 'CLEANUPHOOK'
|
|
295
|
+
#!/bin/bash
|
|
296
|
+
# DNS-01 cleanup hook for ${providerName}
|
|
297
|
+
# This removes the TXT record after validation
|
|
298
|
+
|
|
299
|
+
DOMAIN="\$CERTBOT_DOMAIN"
|
|
300
|
+
VALIDATION="\$CERTBOT_VALIDATION"
|
|
301
|
+
RECORD_NAME="_acme-challenge.\$DOMAIN"
|
|
302
|
+
|
|
303
|
+
${generateDnsDeleteRecordScript(dnsProvider)}
|
|
304
|
+
CLEANUPHOOK
|
|
305
|
+
|
|
306
|
+
chmod +x /etc/letsencrypt/hooks/cleanup-hook.sh
|
|
307
|
+
|
|
308
|
+
# Obtain certificate using manual DNS-01 challenge with hooks
|
|
309
|
+
certbot certonly \\
|
|
310
|
+
--manual \\
|
|
311
|
+
--preferred-challenges dns \\
|
|
312
|
+
--manual-auth-hook /etc/letsencrypt/hooks/auth-hook.sh \\
|
|
313
|
+
--manual-cleanup-hook /etc/letsencrypt/hooks/cleanup-hook.sh \\
|
|
314
|
+
--non-interactive \\
|
|
315
|
+
--agree-tos \\
|
|
316
|
+
--email ${email} \\
|
|
317
|
+
${stagingFlag} \\
|
|
318
|
+
${domainFlags}
|
|
319
|
+
|
|
320
|
+
# Check if certificate was obtained
|
|
321
|
+
if [ -f "${certPath}/${primaryDomain}/fullchain.pem" ]; then
|
|
322
|
+
echo "Certificate obtained successfully!"
|
|
323
|
+
|
|
324
|
+
# Create symlinks for easier access
|
|
325
|
+
mkdir -p /etc/ssl/stacks
|
|
326
|
+
ln -sf ${certPath}/${primaryDomain}/fullchain.pem /etc/ssl/stacks/fullchain.pem
|
|
327
|
+
ln -sf ${certPath}/${primaryDomain}/privkey.pem /etc/ssl/stacks/privkey.pem
|
|
328
|
+
ln -sf ${certPath}/${primaryDomain}/cert.pem /etc/ssl/stacks/cert.pem
|
|
329
|
+
ln -sf ${certPath}/${primaryDomain}/chain.pem /etc/ssl/stacks/chain.pem
|
|
330
|
+
else
|
|
331
|
+
echo "Failed to obtain certificate!"
|
|
332
|
+
fi
|
|
333
|
+
|
|
334
|
+
${autoRenew ? generateRenewalSetup(primaryDomain) : '# Auto-renewal disabled'}
|
|
335
|
+
`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Generate environment variables for DNS provider
|
|
340
|
+
*/
|
|
341
|
+
function generateDnsProviderEnvVars(config: DnsProviderConfig): string {
|
|
342
|
+
switch (config.provider) {
|
|
343
|
+
case 'porkbun':
|
|
344
|
+
return `
|
|
345
|
+
# Porkbun API credentials
|
|
346
|
+
export PORKBUN_API_KEY="${config.apiKey}"
|
|
347
|
+
export PORKBUN_SECRET_KEY="${config.secretKey}"
|
|
348
|
+
`
|
|
349
|
+
case 'godaddy':
|
|
350
|
+
return `
|
|
351
|
+
# GoDaddy API credentials
|
|
352
|
+
export GODADDY_API_KEY="${config.apiKey}"
|
|
353
|
+
export GODADDY_API_SECRET="${config.apiSecret}"
|
|
354
|
+
export GODADDY_ENV="${config.environment || 'production'}"
|
|
355
|
+
`
|
|
356
|
+
case 'route53':
|
|
357
|
+
return `
|
|
358
|
+
# Route53 uses IAM role credentials
|
|
359
|
+
`
|
|
360
|
+
default:
|
|
361
|
+
return ''
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Generate DNS record creation script for the provider
|
|
367
|
+
*/
|
|
368
|
+
function generateDnsCreateRecordScript(config: DnsProviderConfig): string {
|
|
369
|
+
switch (config.provider) {
|
|
370
|
+
case 'porkbun':
|
|
371
|
+
return `
|
|
372
|
+
# Extract root domain (last two parts)
|
|
373
|
+
ROOT_DOMAIN=$(echo "$DOMAIN" | awk -F. '{print $(NF-1)"."$NF}')
|
|
374
|
+
SUBDOMAIN="_acme-challenge"
|
|
375
|
+
if [ "$DOMAIN" != "$ROOT_DOMAIN" ]; then
|
|
376
|
+
SUBDOMAIN="_acme-challenge.$(echo "$DOMAIN" | sed "s/\\.$ROOT_DOMAIN$//")"
|
|
377
|
+
fi
|
|
378
|
+
|
|
379
|
+
echo "Creating TXT record via Porkbun: $SUBDOMAIN.$ROOT_DOMAIN -> $VALIDATION"
|
|
380
|
+
|
|
381
|
+
curl -s -X POST "https://api.porkbun.com/api/json/v3/dns/create/$ROOT_DOMAIN" \\
|
|
382
|
+
-H "Content-Type: application/json" \\
|
|
383
|
+
-d '{
|
|
384
|
+
"apikey": "'"$PORKBUN_API_KEY"'",
|
|
385
|
+
"secretapikey": "'"$PORKBUN_SECRET_KEY"'",
|
|
386
|
+
"type": "TXT",
|
|
387
|
+
"name": "'"$SUBDOMAIN"'",
|
|
388
|
+
"content": "'"$VALIDATION"'",
|
|
389
|
+
"ttl": "600"
|
|
390
|
+
}'
|
|
391
|
+
`
|
|
392
|
+
case 'godaddy':
|
|
393
|
+
return `
|
|
394
|
+
# Extract root domain
|
|
395
|
+
ROOT_DOMAIN=$(echo "$DOMAIN" | awk -F. '{print $(NF-1)"."$NF}')
|
|
396
|
+
RECORD_NAME="_acme-challenge"
|
|
397
|
+
if [ "$DOMAIN" != "$ROOT_DOMAIN" ]; then
|
|
398
|
+
RECORD_NAME="_acme-challenge.$(echo "$DOMAIN" | sed "s/\\.$ROOT_DOMAIN$//")"
|
|
399
|
+
fi
|
|
400
|
+
|
|
401
|
+
echo "Creating TXT record via GoDaddy: $RECORD_NAME.$ROOT_DOMAIN -> $VALIDATION"
|
|
402
|
+
|
|
403
|
+
API_URL="https://api.godaddy.com"
|
|
404
|
+
if [ "$GODADDY_ENV" = "ote" ]; then
|
|
405
|
+
API_URL="https://api.ote-godaddy.com"
|
|
406
|
+
fi
|
|
407
|
+
|
|
408
|
+
curl -s -X PATCH "$API_URL/v1/domains/$ROOT_DOMAIN/records" \\
|
|
409
|
+
-H "Authorization: sso-key $GODADDY_API_KEY:$GODADDY_API_SECRET" \\
|
|
410
|
+
-H "Content-Type: application/json" \\
|
|
411
|
+
-d '[{
|
|
412
|
+
"type": "TXT",
|
|
413
|
+
"name": "'"$RECORD_NAME"'",
|
|
414
|
+
"data": "'"$VALIDATION"'",
|
|
415
|
+
"ttl": 600
|
|
416
|
+
}]'
|
|
417
|
+
`
|
|
418
|
+
default:
|
|
419
|
+
return 'echo "Unsupported DNS provider"'
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Generate DNS record deletion script for the provider
|
|
425
|
+
*/
|
|
426
|
+
function generateDnsDeleteRecordScript(config: DnsProviderConfig): string {
|
|
427
|
+
switch (config.provider) {
|
|
428
|
+
case 'porkbun':
|
|
429
|
+
return `
|
|
430
|
+
# Extract root domain
|
|
431
|
+
ROOT_DOMAIN=$(echo "$DOMAIN" | awk -F. '{print $(NF-1)"."$NF}')
|
|
432
|
+
|
|
433
|
+
echo "Deleting TXT record via Porkbun for _acme-challenge.$DOMAIN"
|
|
434
|
+
|
|
435
|
+
# First, get all TXT records to find the ID
|
|
436
|
+
RECORDS=$(curl -s -X POST "https://api.porkbun.com/api/json/v3/dns/retrieveByNameType/$ROOT_DOMAIN/TXT" \\
|
|
437
|
+
-H "Content-Type: application/json" \\
|
|
438
|
+
-d '{
|
|
439
|
+
"apikey": "'"$PORKBUN_API_KEY"'",
|
|
440
|
+
"secretapikey": "'"$PORKBUN_SECRET_KEY"'"
|
|
441
|
+
}')
|
|
442
|
+
|
|
443
|
+
# Extract record ID for _acme-challenge and delete it
|
|
444
|
+
RECORD_ID=$(echo "$RECORDS" | jq -r '.records[] | select(.name | contains("_acme-challenge")) | .id' | head -1)
|
|
445
|
+
|
|
446
|
+
if [ -n "$RECORD_ID" ] && [ "$RECORD_ID" != "null" ]; then
|
|
447
|
+
curl -s -X POST "https://api.porkbun.com/api/json/v3/dns/delete/$ROOT_DOMAIN/$RECORD_ID" \\
|
|
448
|
+
-H "Content-Type: application/json" \\
|
|
449
|
+
-d '{
|
|
450
|
+
"apikey": "'"$PORKBUN_API_KEY"'",
|
|
451
|
+
"secretapikey": "'"$PORKBUN_SECRET_KEY"'"
|
|
452
|
+
}'
|
|
453
|
+
echo "Deleted record ID: $RECORD_ID"
|
|
454
|
+
fi
|
|
455
|
+
`
|
|
456
|
+
case 'godaddy':
|
|
457
|
+
return `
|
|
458
|
+
# Extract root domain
|
|
459
|
+
ROOT_DOMAIN=$(echo "$DOMAIN" | awk -F. '{print $(NF-1)"."$NF}')
|
|
460
|
+
RECORD_NAME="_acme-challenge"
|
|
461
|
+
if [ "$DOMAIN" != "$ROOT_DOMAIN" ]; then
|
|
462
|
+
RECORD_NAME="_acme-challenge.$(echo "$DOMAIN" | sed "s/\\.$ROOT_DOMAIN$//")"
|
|
463
|
+
fi
|
|
464
|
+
|
|
465
|
+
echo "Deleting TXT record via GoDaddy: $RECORD_NAME.$ROOT_DOMAIN"
|
|
466
|
+
|
|
467
|
+
API_URL="https://api.godaddy.com"
|
|
468
|
+
if [ "$GODADDY_ENV" = "ote" ]; then
|
|
469
|
+
API_URL="https://api.ote-godaddy.com"
|
|
470
|
+
fi
|
|
471
|
+
|
|
472
|
+
curl -s -X DELETE "$API_URL/v1/domains/$ROOT_DOMAIN/records/TXT/$RECORD_NAME" \\
|
|
473
|
+
-H "Authorization: sso-key $GODADDY_API_KEY:$GODADDY_API_SECRET"
|
|
474
|
+
`
|
|
475
|
+
default:
|
|
476
|
+
return 'echo "Unsupported DNS provider"'
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Generate certificate renewal setup
|
|
482
|
+
*/
|
|
483
|
+
function generateRenewalSetup(primaryDomain: string): string {
|
|
484
|
+
return `
|
|
485
|
+
# ==========================================
|
|
486
|
+
# Certificate Auto-Renewal Setup
|
|
487
|
+
# ==========================================
|
|
488
|
+
|
|
489
|
+
# Create renewal hook to restart the application
|
|
490
|
+
cat > /etc/letsencrypt/renewal-hooks/deploy/restart-stacks.sh << 'RENEWHOOK'
|
|
491
|
+
#!/bin/bash
|
|
492
|
+
# Restart the Stacks application after certificate renewal
|
|
493
|
+
systemctl restart stacks
|
|
494
|
+
echo "Certificate renewed and application restarted at $(date)"
|
|
495
|
+
RENEWHOOK
|
|
496
|
+
|
|
497
|
+
chmod +x /etc/letsencrypt/renewal-hooks/deploy/restart-stacks.sh
|
|
498
|
+
|
|
499
|
+
# Create systemd timer for automatic renewal
|
|
500
|
+
cat > /etc/systemd/system/certbot-renewal.service << 'RENEWSERVICE'
|
|
501
|
+
[Unit]
|
|
502
|
+
Description=Certbot Renewal
|
|
503
|
+
After=network-online.target
|
|
504
|
+
|
|
505
|
+
[Service]
|
|
506
|
+
Type=oneshot
|
|
507
|
+
ExecStart=/usr/bin/certbot renew --quiet
|
|
508
|
+
RENEWSERVICE
|
|
509
|
+
|
|
510
|
+
cat > /etc/systemd/system/certbot-renewal.timer << 'RENEWTIMER'
|
|
511
|
+
[Unit]
|
|
512
|
+
Description=Run certbot renewal twice daily
|
|
513
|
+
|
|
514
|
+
[Timer]
|
|
515
|
+
OnCalendar=*-*-* 00,12:00:00
|
|
516
|
+
RandomizedDelaySec=3600
|
|
517
|
+
Persistent=true
|
|
518
|
+
|
|
519
|
+
[Install]
|
|
520
|
+
WantedBy=timers.target
|
|
521
|
+
RENEWTIMER
|
|
522
|
+
|
|
523
|
+
# Enable and start the renewal timer
|
|
524
|
+
systemctl daemon-reload
|
|
525
|
+
systemctl enable certbot-renewal.timer
|
|
526
|
+
systemctl start certbot-renewal.timer
|
|
527
|
+
|
|
528
|
+
echo "Certificate auto-renewal configured"
|
|
529
|
+
`
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Generate server configuration for HTTPS with Let's Encrypt
|
|
534
|
+
*/
|
|
535
|
+
export function generateHttpsServerCode(options: {
|
|
536
|
+
httpPort?: number
|
|
537
|
+
httpsPort?: number
|
|
538
|
+
certPath?: string
|
|
539
|
+
redirectHttp?: boolean
|
|
540
|
+
}): string {
|
|
541
|
+
const {
|
|
542
|
+
httpPort = 80,
|
|
543
|
+
httpsPort = 443,
|
|
544
|
+
certPath = '/etc/ssl/stacks',
|
|
545
|
+
redirectHttp = true,
|
|
546
|
+
} = options
|
|
547
|
+
|
|
548
|
+
return `
|
|
549
|
+
// HTTPS Server with Let's Encrypt certificates
|
|
550
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
551
|
+
|
|
552
|
+
const CERT_PATH = '${certPath}'
|
|
553
|
+
const HTTP_PORT = ${httpPort}
|
|
554
|
+
const HTTPS_PORT = ${httpsPort}
|
|
555
|
+
|
|
556
|
+
// Check if certificates exist
|
|
557
|
+
const hasCerts = existsSync(\`\${CERT_PATH}/fullchain.pem\`) && existsSync(\`\${CERT_PATH}/privkey.pem\`)
|
|
558
|
+
|
|
559
|
+
if (hasCerts) {
|
|
560
|
+
// Start HTTPS server
|
|
561
|
+
const httpsServer = Bun.serve({
|
|
562
|
+
port: HTTPS_PORT,
|
|
563
|
+
tls: {
|
|
564
|
+
cert: Bun.file(\`\${CERT_PATH}/fullchain.pem\`),
|
|
565
|
+
key: Bun.file(\`\${CERT_PATH}/privkey.pem\`),
|
|
566
|
+
},
|
|
567
|
+
async fetch(request: Request): Promise<Response> {
|
|
568
|
+
// Your application handler here
|
|
569
|
+
return handleRequest(request)
|
|
570
|
+
},
|
|
571
|
+
})
|
|
572
|
+
console.log(\`HTTPS server running on port \${HTTPS_PORT}\`)
|
|
573
|
+
|
|
574
|
+
${redirectHttp
|
|
575
|
+
? `
|
|
576
|
+
// Start HTTP server for redirect
|
|
577
|
+
const httpServer = Bun.serve({
|
|
578
|
+
port: HTTP_PORT,
|
|
579
|
+
async fetch(request: Request): Promise<Response> {
|
|
580
|
+
const url = new URL(request.url)
|
|
581
|
+
|
|
582
|
+
// Allow ACME challenges through
|
|
583
|
+
if (url.pathname.startsWith('/.well-known/acme-challenge/')) {
|
|
584
|
+
return handleAcmeChallenge(request)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Redirect to HTTPS
|
|
588
|
+
return Response.redirect(
|
|
589
|
+
\`https://\${url.host}\${url.pathname}\${url.search}\`,
|
|
590
|
+
301
|
|
591
|
+
)
|
|
592
|
+
},
|
|
593
|
+
})
|
|
594
|
+
console.log(\`HTTP redirect server running on port \${HTTP_PORT}\`)
|
|
595
|
+
`
|
|
596
|
+
: ''}
|
|
597
|
+
} else {
|
|
598
|
+
// No certificates yet, run HTTP only
|
|
599
|
+
console.log('No SSL certificates found, running HTTP only')
|
|
600
|
+
const httpServer = Bun.serve({
|
|
601
|
+
port: HTTP_PORT,
|
|
602
|
+
async fetch(request: Request): Promise<Response> {
|
|
603
|
+
const url = new URL(request.url)
|
|
604
|
+
|
|
605
|
+
// Allow ACME challenges
|
|
606
|
+
if (url.pathname.startsWith('/.well-known/acme-challenge/')) {
|
|
607
|
+
return handleAcmeChallenge(request)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return handleRequest(request)
|
|
611
|
+
},
|
|
612
|
+
})
|
|
613
|
+
console.log(\`HTTP server running on port \${HTTP_PORT}\`)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ACME challenge handler for HTTP-01 validation
|
|
617
|
+
async function handleAcmeChallenge(request: Request): Promise<Response> {
|
|
618
|
+
const url = new URL(request.url)
|
|
619
|
+
const token = url.pathname.split('/').pop()
|
|
620
|
+
|
|
621
|
+
// Check for challenge file
|
|
622
|
+
const challengePath = \`/var/www/.well-known/acme-challenge/\${token}\`
|
|
623
|
+
if (existsSync(challengePath)) {
|
|
624
|
+
return new Response(Bun.file(challengePath))
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return new Response('Not Found', { status: 404 })
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Your application request handler
|
|
631
|
+
async function handleRequest(request: Request): Promise<Response> {
|
|
632
|
+
// Implement your request handling here
|
|
633
|
+
return new Response('Hello from Stacks!')
|
|
634
|
+
}
|
|
635
|
+
`
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Setup DNS-01 challenge programmatically using any DNS provider
|
|
640
|
+
* This is the unified API that works with Route53, Porkbun, GoDaddy, etc.
|
|
641
|
+
*/
|
|
642
|
+
export async function setupDns01Challenge(options: Dns01ChallengeConfig): Promise<void> {
|
|
643
|
+
const { domain, challengeValue, hostedZoneId, dnsProvider, region = 'us-east-1' } = options
|
|
644
|
+
|
|
645
|
+
// Use the unified DNS provider abstraction if available
|
|
646
|
+
if (dnsProvider) {
|
|
647
|
+
const provider: DnsProvider = createDnsProvider(dnsProvider)
|
|
648
|
+
const result = await provider.upsertRecord(domain, {
|
|
649
|
+
name: `_acme-challenge.${domain}`,
|
|
650
|
+
type: 'TXT',
|
|
651
|
+
content: challengeValue,
|
|
652
|
+
ttl: 60,
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
if (!result.success) {
|
|
656
|
+
throw new Error(`Failed to create DNS challenge record: ${result.message}`)
|
|
657
|
+
}
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Legacy: Use Route53 directly if hostedZoneId is provided
|
|
662
|
+
if (hostedZoneId) {
|
|
663
|
+
const r53 = new Route53Client(region)
|
|
664
|
+
|
|
665
|
+
await r53.changeResourceRecordSets({
|
|
666
|
+
HostedZoneId: hostedZoneId,
|
|
667
|
+
ChangeBatch: {
|
|
668
|
+
Comment: 'ACME DNS-01 challenge',
|
|
669
|
+
Changes: [{
|
|
670
|
+
Action: 'UPSERT',
|
|
671
|
+
ResourceRecordSet: {
|
|
672
|
+
Name: `_acme-challenge.${domain}`,
|
|
673
|
+
Type: 'TXT',
|
|
674
|
+
TTL: 60,
|
|
675
|
+
ResourceRecords: [{ Value: `"${challengeValue}"` }],
|
|
676
|
+
},
|
|
677
|
+
}],
|
|
678
|
+
},
|
|
679
|
+
})
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
throw new Error('Either dnsProvider or hostedZoneId must be provided')
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Clean up DNS-01 challenge record using any DNS provider
|
|
688
|
+
*/
|
|
689
|
+
export async function cleanupDns01Challenge(options: Dns01ChallengeConfig): Promise<void> {
|
|
690
|
+
const { domain, challengeValue, hostedZoneId, dnsProvider, region = 'us-east-1' } = options
|
|
691
|
+
|
|
692
|
+
// Use the unified DNS provider abstraction if available
|
|
693
|
+
if (dnsProvider) {
|
|
694
|
+
const provider: DnsProvider = createDnsProvider(dnsProvider)
|
|
695
|
+
const result = await provider.deleteRecord(domain, {
|
|
696
|
+
name: `_acme-challenge.${domain}`,
|
|
697
|
+
type: 'TXT',
|
|
698
|
+
content: challengeValue,
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
if (!result.success) {
|
|
702
|
+
console.warn(`Failed to delete DNS challenge record: ${result.message}`)
|
|
703
|
+
}
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Legacy: Use Route53 directly if hostedZoneId is provided
|
|
708
|
+
if (hostedZoneId) {
|
|
709
|
+
const r53 = new Route53Client(region)
|
|
710
|
+
|
|
711
|
+
await r53.changeResourceRecordSets({
|
|
712
|
+
HostedZoneId: hostedZoneId,
|
|
713
|
+
ChangeBatch: {
|
|
714
|
+
Comment: 'Remove ACME DNS-01 challenge',
|
|
715
|
+
Changes: [{
|
|
716
|
+
Action: 'DELETE',
|
|
717
|
+
ResourceRecordSet: {
|
|
718
|
+
Name: `_acme-challenge.${domain}`,
|
|
719
|
+
Type: 'TXT',
|
|
720
|
+
TTL: 60,
|
|
721
|
+
ResourceRecords: [{ Value: `"${challengeValue}"` }],
|
|
722
|
+
},
|
|
723
|
+
}],
|
|
724
|
+
},
|
|
725
|
+
})
|
|
726
|
+
return
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
throw new Error('Either dnsProvider or hostedZoneId must be provided')
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Check if certificates need renewal (< 30 days until expiry)
|
|
734
|
+
*/
|
|
735
|
+
export function needsRenewal(certPath: string): boolean {
|
|
736
|
+
try {
|
|
737
|
+
const { execSync } = require('node:child_process')
|
|
738
|
+
const result = execSync(
|
|
739
|
+
`openssl x509 -checkend 2592000 -noout -in ${certPath}/cert.pem`,
|
|
740
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
|
741
|
+
)
|
|
742
|
+
return false // Certificate is still valid for > 30 days
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
return true // Certificate expires within 30 days or doesn't exist
|
|
746
|
+
}
|
|
747
|
+
}
|