@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.
Files changed (75) hide show
  1. package/dist/bin/cli.js +1 -1
  2. package/package.json +18 -16
  3. package/src/aws/acm.ts +768 -0
  4. package/src/aws/application-autoscaling.ts +845 -0
  5. package/src/aws/bedrock.ts +4074 -0
  6. package/src/aws/client.ts +891 -0
  7. package/src/aws/cloudformation.ts +896 -0
  8. package/src/aws/cloudfront.ts +1531 -0
  9. package/src/aws/cloudwatch-logs.ts +154 -0
  10. package/src/aws/comprehend.ts +839 -0
  11. package/src/aws/connect.ts +1056 -0
  12. package/src/aws/deploy-imap.ts +384 -0
  13. package/src/aws/dynamodb.ts +340 -0
  14. package/src/aws/ec2.ts +1385 -0
  15. package/src/aws/ecr.ts +621 -0
  16. package/src/aws/ecs.ts +615 -0
  17. package/src/aws/elasticache.ts +301 -0
  18. package/src/aws/elbv2.ts +942 -0
  19. package/src/aws/email.ts +928 -0
  20. package/src/aws/eventbridge.ts +248 -0
  21. package/src/aws/iam.ts +1689 -0
  22. package/src/aws/imap-server.ts +2100 -0
  23. package/src/aws/index.ts +213 -0
  24. package/src/aws/kendra.ts +1097 -0
  25. package/src/aws/lambda.ts +786 -0
  26. package/src/aws/opensearch.ts +158 -0
  27. package/src/aws/personalize.ts +977 -0
  28. package/src/aws/polly.ts +559 -0
  29. package/src/aws/rds.ts +888 -0
  30. package/src/aws/rekognition.ts +846 -0
  31. package/src/aws/route53-domains.ts +359 -0
  32. package/src/aws/route53.ts +1046 -0
  33. package/src/aws/s3.ts +2334 -0
  34. package/src/aws/scheduler.ts +571 -0
  35. package/src/aws/secrets-manager.ts +769 -0
  36. package/src/aws/ses.ts +1081 -0
  37. package/src/aws/setup-phone.ts +104 -0
  38. package/src/aws/setup-sms.ts +580 -0
  39. package/src/aws/sms.ts +1735 -0
  40. package/src/aws/smtp-server.ts +531 -0
  41. package/src/aws/sns.ts +758 -0
  42. package/src/aws/sqs.ts +382 -0
  43. package/src/aws/ssm.ts +807 -0
  44. package/src/aws/sts.ts +92 -0
  45. package/src/aws/support.ts +391 -0
  46. package/src/aws/test-imap.ts +86 -0
  47. package/src/aws/textract.ts +780 -0
  48. package/src/aws/transcribe.ts +108 -0
  49. package/src/aws/translate.ts +641 -0
  50. package/src/aws/voice.ts +1379 -0
  51. package/src/config.ts +35 -0
  52. package/src/deploy/index.ts +7 -0
  53. package/src/deploy/static-site-external-dns.ts +945 -0
  54. package/src/deploy/static-site.ts +1175 -0
  55. package/src/dns/cloudflare.ts +548 -0
  56. package/src/dns/godaddy.ts +412 -0
  57. package/src/dns/index.ts +205 -0
  58. package/src/dns/porkbun.ts +362 -0
  59. package/src/dns/route53-adapter.ts +414 -0
  60. package/src/dns/types.ts +119 -0
  61. package/src/dns/validator.ts +369 -0
  62. package/src/generators/index.ts +5 -0
  63. package/src/generators/infrastructure.ts +1660 -0
  64. package/src/index.ts +163 -0
  65. package/src/push/apns.ts +452 -0
  66. package/src/push/fcm.ts +506 -0
  67. package/src/push/index.ts +58 -0
  68. package/src/security/pre-deploy-scanner.ts +655 -0
  69. package/src/ssl/acme-client.ts +478 -0
  70. package/src/ssl/index.ts +7 -0
  71. package/src/ssl/letsencrypt.ts +747 -0
  72. package/src/types.ts +2 -0
  73. package/src/utils/cli.ts +398 -0
  74. package/src/validation/index.ts +5 -0
  75. 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
+ }