suthep 1.0.0
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/.editorconfig +17 -0
- package/.github/workflows/publish.yml +42 -0
- package/.prettierignore +6 -0
- package/.prettierrc +7 -0
- package/.scannerwork/.sonar_lock +0 -0
- package/.scannerwork/report-task.txt +6 -0
- package/.vscode/settings.json +19 -0
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/dist/commands/deploy.js +371 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/down.js +179 -0
- package/dist/commands/down.js.map +1 -0
- package/dist/commands/init.js +188 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/setup.js +90 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/up.js +213 -0
- package/dist/commands/up.js.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/certbot.js +64 -0
- package/dist/utils/certbot.js.map +1 -0
- package/dist/utils/config-loader.js +127 -0
- package/dist/utils/config-loader.js.map +1 -0
- package/dist/utils/deployment.js +85 -0
- package/dist/utils/deployment.js.map +1 -0
- package/dist/utils/docker.js +425 -0
- package/dist/utils/docker.js.map +1 -0
- package/dist/utils/env-loader.js +53 -0
- package/dist/utils/env-loader.js.map +1 -0
- package/dist/utils/nginx.js +378 -0
- package/dist/utils/nginx.js.map +1 -0
- package/docs/README.md +38 -0
- package/docs/english/01-introduction.md +84 -0
- package/docs/english/02-installation.md +200 -0
- package/docs/english/03-quick-start.md +258 -0
- package/docs/english/04-configuration.md +433 -0
- package/docs/english/05-commands.md +336 -0
- package/docs/english/06-examples.md +456 -0
- package/docs/english/07-troubleshooting.md +417 -0
- package/docs/english/08-advanced.md +411 -0
- package/docs/english/README.md +48 -0
- package/docs/thai/01-introduction.md +84 -0
- package/docs/thai/02-installation.md +200 -0
- package/docs/thai/03-quick-start.md +258 -0
- package/docs/thai/04-configuration.md +433 -0
- package/docs/thai/05-commands.md +336 -0
- package/docs/thai/06-examples.md +456 -0
- package/docs/thai/07-troubleshooting.md +417 -0
- package/docs/thai/08-advanced.md +411 -0
- package/docs/thai/README.md +48 -0
- package/example/suthep-complete.yml +103 -0
- package/example/suthep-docker-only.yml +71 -0
- package/example/suthep-env-example.yml +113 -0
- package/example/suthep-no-docker.yml +51 -0
- package/example/suthep-path-routing.yml +62 -0
- package/example/suthep.example.yml +88 -0
- package/package.json +51 -0
- package/src/commands/deploy.ts +488 -0
- package/src/commands/down.ts +240 -0
- package/src/commands/init.ts +214 -0
- package/src/commands/setup.ts +112 -0
- package/src/commands/up.ts +271 -0
- package/src/index.ts +109 -0
- package/src/types/config.ts +52 -0
- package/src/utils/__tests__/certbot.test.ts +222 -0
- package/src/utils/__tests__/config-loader.test.ts +419 -0
- package/src/utils/__tests__/deployment.test.ts +243 -0
- package/src/utils/__tests__/nginx.test.ts +412 -0
- package/src/utils/certbot.ts +144 -0
- package/src/utils/config-loader.ts +184 -0
- package/src/utils/deployment.ts +157 -0
- package/src/utils/docker.ts +768 -0
- package/src/utils/env-loader.ts +135 -0
- package/src/utils/nginx.ts +443 -0
- package/suthep-1.0.0.tgz +0 -0
- package/suthep.example.yml +98 -0
- package/suthep.yml +39 -0
- package/todo.md +6 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +46 -0
- package/vitest.config.ts +21 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import fs from 'fs-extra'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { ServiceConfig } from '../../types/config'
|
|
5
|
+
import {
|
|
6
|
+
configExists,
|
|
7
|
+
disableSite,
|
|
8
|
+
enableSite,
|
|
9
|
+
generateMultiServiceNginxConfig,
|
|
10
|
+
generateNginxConfig,
|
|
11
|
+
reloadNginx,
|
|
12
|
+
writeNginxConfig,
|
|
13
|
+
} from '../nginx'
|
|
14
|
+
|
|
15
|
+
// Mock fs-extra
|
|
16
|
+
vi.mock('fs-extra', () => ({
|
|
17
|
+
default: {
|
|
18
|
+
pathExists: vi.fn(),
|
|
19
|
+
remove: vi.fn(),
|
|
20
|
+
writeFile: vi.fn(),
|
|
21
|
+
ensureDir: vi.fn(),
|
|
22
|
+
},
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
// Mock execa
|
|
26
|
+
vi.mock('execa', () => ({
|
|
27
|
+
execa: vi.fn(),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
describe('nginx', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('generateNginxConfig', () => {
|
|
36
|
+
it('should generate HTTP-only config for service', () => {
|
|
37
|
+
const service: ServiceConfig = {
|
|
38
|
+
name: 'api',
|
|
39
|
+
port: 3000,
|
|
40
|
+
domains: ['api.example.com'],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const config = generateNginxConfig(service, false)
|
|
44
|
+
|
|
45
|
+
expect(config).toContain('server_name api.example.com')
|
|
46
|
+
expect(config).toContain('listen 80')
|
|
47
|
+
expect(config).toContain('server localhost:3000')
|
|
48
|
+
expect(config).not.toContain('listen 443')
|
|
49
|
+
expect(config).not.toContain('ssl_certificate')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should generate HTTPS config with redirect for service', () => {
|
|
53
|
+
const service: ServiceConfig = {
|
|
54
|
+
name: 'api',
|
|
55
|
+
port: 3000,
|
|
56
|
+
domains: ['api.example.com'],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const config = generateNginxConfig(service, true)
|
|
60
|
+
|
|
61
|
+
expect(config).toContain('listen 443 ssl http2')
|
|
62
|
+
expect(config).toContain('ssl_certificate')
|
|
63
|
+
expect(config).toContain('/etc/letsencrypt/live/api.example.com/fullchain.pem')
|
|
64
|
+
expect(config).toContain('return 301 https://$server_name$request_uri')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should combine www and root domain in same server_name', () => {
|
|
68
|
+
const service: ServiceConfig = {
|
|
69
|
+
name: 'web',
|
|
70
|
+
port: 3000,
|
|
71
|
+
domains: ['muacle.com', 'www.muacle.com'],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const config = generateNginxConfig(service, false)
|
|
75
|
+
|
|
76
|
+
// Should combine both www and non-www in same server_name
|
|
77
|
+
expect(config).toContain('server_name www.muacle.com muacle.com')
|
|
78
|
+
expect(config).not.toContain('return 301')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should not redirect subdomains', () => {
|
|
82
|
+
const service: ServiceConfig = {
|
|
83
|
+
name: 'api',
|
|
84
|
+
port: 3000,
|
|
85
|
+
domains: ['dev.muacle.com'],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const config = generateNginxConfig(service, false)
|
|
89
|
+
|
|
90
|
+
// Subdomains should not have redirect logic
|
|
91
|
+
expect(config).toContain('server_name dev.muacle.com')
|
|
92
|
+
expect(config).not.toContain('return 301')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should include health check location if configured', () => {
|
|
96
|
+
const service: ServiceConfig = {
|
|
97
|
+
name: 'api',
|
|
98
|
+
port: 3000,
|
|
99
|
+
domains: ['api.example.com'],
|
|
100
|
+
healthCheck: {
|
|
101
|
+
path: '/health',
|
|
102
|
+
interval: 30,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const config = generateNginxConfig(service, false)
|
|
107
|
+
|
|
108
|
+
expect(config).toContain('location /health')
|
|
109
|
+
expect(config).toContain('access_log off')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should use custom path if specified', () => {
|
|
113
|
+
const service: ServiceConfig = {
|
|
114
|
+
name: 'api',
|
|
115
|
+
port: 3000,
|
|
116
|
+
domains: ['api.example.com'],
|
|
117
|
+
path: '/api',
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const config = generateNginxConfig(service, false)
|
|
121
|
+
|
|
122
|
+
expect(config).toContain('location /api')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should use port override if provided', () => {
|
|
126
|
+
const service: ServiceConfig = {
|
|
127
|
+
name: 'api',
|
|
128
|
+
port: 3000,
|
|
129
|
+
domains: ['api.example.com'],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const config = generateNginxConfig(service, false, 4000)
|
|
133
|
+
|
|
134
|
+
expect(config).toContain('server localhost:4000')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should generate unique upstream names for domains with special characters', () => {
|
|
138
|
+
const service: ServiceConfig = {
|
|
139
|
+
name: 'api',
|
|
140
|
+
port: 3000,
|
|
141
|
+
domains: ['api-test.example.com'],
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const config = generateNginxConfig(service, false)
|
|
145
|
+
|
|
146
|
+
expect(config).toMatch(/upstream [a-zA-Z0-9_]+_api/)
|
|
147
|
+
expect(config).toContain('proxy_pass http://')
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('generateMultiServiceNginxConfig', () => {
|
|
152
|
+
it('should generate config for multiple services on same domain', () => {
|
|
153
|
+
const services: ServiceConfig[] = [
|
|
154
|
+
{
|
|
155
|
+
name: 'api',
|
|
156
|
+
port: 3000,
|
|
157
|
+
domains: ['example.com'],
|
|
158
|
+
path: '/api',
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'web',
|
|
162
|
+
port: 8080,
|
|
163
|
+
domains: ['example.com'],
|
|
164
|
+
path: '/',
|
|
165
|
+
},
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
const config = generateMultiServiceNginxConfig(services, 'example.com', false)
|
|
169
|
+
|
|
170
|
+
expect(config).toContain('server_name example.com')
|
|
171
|
+
expect(config).toContain('location /api')
|
|
172
|
+
expect(config).toContain('location /')
|
|
173
|
+
expect(config).toContain('server localhost:3000')
|
|
174
|
+
expect(config).toContain('server localhost:8080')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('should sort services by path length (longest first)', () => {
|
|
178
|
+
const services: ServiceConfig[] = [
|
|
179
|
+
{
|
|
180
|
+
name: 'root',
|
|
181
|
+
port: 8080,
|
|
182
|
+
domains: ['example.com'],
|
|
183
|
+
path: '/',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'api',
|
|
187
|
+
port: 3000,
|
|
188
|
+
domains: ['example.com'],
|
|
189
|
+
path: '/api/v1',
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: 'v2',
|
|
193
|
+
port: 3001,
|
|
194
|
+
domains: ['example.com'],
|
|
195
|
+
path: '/api',
|
|
196
|
+
},
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
const config = generateMultiServiceNginxConfig(services, 'example.com', false)
|
|
200
|
+
|
|
201
|
+
// Find the order of location blocks by checking which service comment appears first
|
|
202
|
+
const apiV1ServiceIndex = config.indexOf('# Service: api')
|
|
203
|
+
const apiServiceIndex = config.indexOf('# Service: v2')
|
|
204
|
+
const rootServiceIndex = config.indexOf('# Service: root')
|
|
205
|
+
|
|
206
|
+
// /api/v1 (api service) should come before /api (v2 service), which should come before / (root service)
|
|
207
|
+
// The path /api/v1 (length 7) should come before /api (length 5), which should come before / (length 1)
|
|
208
|
+
expect(apiV1ServiceIndex).toBeGreaterThan(-1)
|
|
209
|
+
expect(apiServiceIndex).toBeGreaterThan(-1)
|
|
210
|
+
expect(rootServiceIndex).toBeGreaterThan(-1)
|
|
211
|
+
expect(apiV1ServiceIndex).toBeLessThan(apiServiceIndex)
|
|
212
|
+
expect(apiServiceIndex).toBeLessThan(rootServiceIndex)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('should generate HTTPS config for multiple services', () => {
|
|
216
|
+
const services: ServiceConfig[] = [
|
|
217
|
+
{
|
|
218
|
+
name: 'api',
|
|
219
|
+
port: 3000,
|
|
220
|
+
domains: ['example.com'],
|
|
221
|
+
path: '/api',
|
|
222
|
+
},
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
const config = generateMultiServiceNginxConfig(services, 'example.com', true)
|
|
226
|
+
|
|
227
|
+
expect(config).toContain('listen 443 ssl http2')
|
|
228
|
+
expect(config).toContain('ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem')
|
|
229
|
+
expect(config).toContain('return 301 https://$server_name$request_uri')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('should include health checks for services that have them', () => {
|
|
233
|
+
const services: ServiceConfig[] = [
|
|
234
|
+
{
|
|
235
|
+
name: 'api',
|
|
236
|
+
port: 3000,
|
|
237
|
+
domains: ['example.com'],
|
|
238
|
+
path: '/api',
|
|
239
|
+
healthCheck: {
|
|
240
|
+
path: '/health',
|
|
241
|
+
interval: 30,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: 'web',
|
|
246
|
+
port: 8080,
|
|
247
|
+
domains: ['example.com'],
|
|
248
|
+
path: '/',
|
|
249
|
+
},
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
const config = generateMultiServiceNginxConfig(services, 'example.com', false)
|
|
253
|
+
|
|
254
|
+
expect(config).toContain('location /health')
|
|
255
|
+
expect(config).toContain('# Health check for api')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('should use port overrides if provided', () => {
|
|
259
|
+
const services: ServiceConfig[] = [
|
|
260
|
+
{
|
|
261
|
+
name: 'api',
|
|
262
|
+
port: 3000,
|
|
263
|
+
domains: ['example.com'],
|
|
264
|
+
path: '/api',
|
|
265
|
+
},
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
const portOverrides = new Map([['api', 4000]])
|
|
269
|
+
|
|
270
|
+
const config = generateMultiServiceNginxConfig(services, 'example.com', false, portOverrides)
|
|
271
|
+
|
|
272
|
+
expect(config).toContain('server localhost:4000')
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
describe('configExists', () => {
|
|
277
|
+
it('should return true if config file exists', async () => {
|
|
278
|
+
vi.mocked(fs.pathExists).mockResolvedValue(true as any)
|
|
279
|
+
|
|
280
|
+
const result = await configExists('example.com', '/etc/nginx/sites-available')
|
|
281
|
+
|
|
282
|
+
expect(fs.pathExists).toHaveBeenCalledWith('/etc/nginx/sites-available/example.com.conf')
|
|
283
|
+
expect(result).toBe(true)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('should return false if config file does not exist', async () => {
|
|
287
|
+
vi.mocked(fs.pathExists).mockResolvedValue(false as any)
|
|
288
|
+
|
|
289
|
+
const result = await configExists('example.com', '/etc/nginx/sites-available')
|
|
290
|
+
|
|
291
|
+
expect(result).toBe(false)
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
describe('writeNginxConfig', () => {
|
|
296
|
+
it('should write config file if it does not exist', async () => {
|
|
297
|
+
vi.mocked(fs.pathExists).mockResolvedValue(false as any)
|
|
298
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined as any)
|
|
299
|
+
|
|
300
|
+
const result = await writeNginxConfig(
|
|
301
|
+
'example.com',
|
|
302
|
+
'/etc/nginx/sites-available',
|
|
303
|
+
'server { ... }'
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
expect(fs.remove).not.toHaveBeenCalled()
|
|
307
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
308
|
+
'/etc/nginx/sites-available/example.com.conf',
|
|
309
|
+
'server { ... }'
|
|
310
|
+
)
|
|
311
|
+
expect(result).toBe(false)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('should delete existing config file before writing new one', async () => {
|
|
315
|
+
vi.mocked(fs.pathExists).mockResolvedValue(true as any)
|
|
316
|
+
vi.mocked(fs.remove).mockResolvedValue(undefined as any)
|
|
317
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined as any)
|
|
318
|
+
|
|
319
|
+
const result = await writeNginxConfig(
|
|
320
|
+
'example.com',
|
|
321
|
+
'/etc/nginx/sites-available',
|
|
322
|
+
'server { ... }'
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
expect(fs.remove).toHaveBeenCalledWith('/etc/nginx/sites-available/example.com.conf')
|
|
326
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
327
|
+
'/etc/nginx/sites-available/example.com.conf',
|
|
328
|
+
'server { ... }'
|
|
329
|
+
)
|
|
330
|
+
expect(result).toBe(true)
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe('enableSite', () => {
|
|
335
|
+
it('should create symlink to enable site', async () => {
|
|
336
|
+
vi.mocked(fs.pathExists).mockResolvedValue(false as any)
|
|
337
|
+
vi.mocked(fs.ensureDir).mockResolvedValue(undefined as any)
|
|
338
|
+
vi.mocked(execa).mockResolvedValue({ stdout: '', stderr: '' } as any)
|
|
339
|
+
|
|
340
|
+
await enableSite('example.com', '/etc/nginx/sites-available')
|
|
341
|
+
|
|
342
|
+
expect(fs.ensureDir).toHaveBeenCalled()
|
|
343
|
+
expect(execa).toHaveBeenCalledWith('sudo', [
|
|
344
|
+
'ln',
|
|
345
|
+
'-sf',
|
|
346
|
+
'/etc/nginx/sites-available/example.com.conf',
|
|
347
|
+
'/etc/nginx/sites-enabled/example.com.conf',
|
|
348
|
+
])
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('should remove existing symlink before creating new one', async () => {
|
|
352
|
+
vi.mocked(fs.pathExists).mockResolvedValue(true as any)
|
|
353
|
+
vi.mocked(fs.remove).mockResolvedValue(undefined as any)
|
|
354
|
+
vi.mocked(fs.ensureDir).mockResolvedValue(undefined as any)
|
|
355
|
+
vi.mocked(execa).mockResolvedValue({ stdout: '', stderr: '' } as any)
|
|
356
|
+
|
|
357
|
+
await enableSite('example.com', '/etc/nginx/sites-available')
|
|
358
|
+
|
|
359
|
+
expect(fs.remove).toHaveBeenCalledWith('/etc/nginx/sites-enabled/example.com.conf')
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
describe('disableSite', () => {
|
|
364
|
+
it('should remove symlink if it exists', async () => {
|
|
365
|
+
vi.mocked(fs.pathExists).mockResolvedValue(true as any)
|
|
366
|
+
vi.mocked(fs.remove).mockResolvedValue(undefined as any)
|
|
367
|
+
|
|
368
|
+
await disableSite('example.com', '/etc/nginx/sites-available')
|
|
369
|
+
|
|
370
|
+
expect(fs.remove).toHaveBeenCalledWith('/etc/nginx/sites-enabled/example.com.conf')
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('should not throw error if symlink does not exist', async () => {
|
|
374
|
+
vi.mocked(fs.pathExists).mockResolvedValue(false as any)
|
|
375
|
+
|
|
376
|
+
await expect(disableSite('example.com', '/etc/nginx/sites-available')).resolves.not.toThrow()
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
describe('reloadNginx', () => {
|
|
381
|
+
it('should test and reload nginx configuration', async () => {
|
|
382
|
+
vi.mocked(execa)
|
|
383
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // nginx -t
|
|
384
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // reload command
|
|
385
|
+
|
|
386
|
+
await reloadNginx('sudo systemctl reload nginx')
|
|
387
|
+
|
|
388
|
+
expect(execa).toHaveBeenCalledWith('sudo', ['nginx', '-t'])
|
|
389
|
+
expect(execa).toHaveBeenCalledWith('sudo', ['systemctl', 'reload', 'nginx'], {
|
|
390
|
+
shell: true,
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('should throw error if nginx test fails', async () => {
|
|
395
|
+
vi.mocked(execa).mockRejectedValue(new Error('Configuration test failed'))
|
|
396
|
+
|
|
397
|
+
await expect(reloadNginx('sudo systemctl reload nginx')).rejects.toThrow(
|
|
398
|
+
'Failed to reload Nginx'
|
|
399
|
+
)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('should throw error if reload command fails', async () => {
|
|
403
|
+
vi.mocked(execa)
|
|
404
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // nginx -t
|
|
405
|
+
.mockRejectedValueOnce(new Error('Reload failed'))
|
|
406
|
+
|
|
407
|
+
await expect(reloadNginx('sudo systemctl reload nginx')).rejects.toThrow(
|
|
408
|
+
'Failed to reload Nginx'
|
|
409
|
+
)
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Request an SSL certificate from Let's Encrypt using Certbot
|
|
5
|
+
*/
|
|
6
|
+
export async function requestCertificate(
|
|
7
|
+
domain: string,
|
|
8
|
+
email: string,
|
|
9
|
+
staging: boolean = false
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
// Check if certificate already exists before requesting
|
|
12
|
+
const exists = await certificateExists(domain)
|
|
13
|
+
if (exists) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Certificate for ${domain} already exists. Use certificateExists() to check before calling this function.`
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const args = [
|
|
20
|
+
'certonly',
|
|
21
|
+
'--nginx',
|
|
22
|
+
'-d',
|
|
23
|
+
domain,
|
|
24
|
+
'--non-interactive',
|
|
25
|
+
'--agree-tos',
|
|
26
|
+
'--email',
|
|
27
|
+
email,
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
if (staging) {
|
|
31
|
+
args.push('--staging')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await execa('sudo', ['certbot', ...args])
|
|
36
|
+
} catch (error: any) {
|
|
37
|
+
const errorMessage = error?.stderr || error?.message || String(error) || 'Unknown error'
|
|
38
|
+
const errorLower = errorMessage.toLowerCase()
|
|
39
|
+
|
|
40
|
+
// Check if error is due to certificate already existing
|
|
41
|
+
if (
|
|
42
|
+
errorLower.includes('certificate already exists') ||
|
|
43
|
+
errorLower.includes('already have a certificate') ||
|
|
44
|
+
errorLower.includes('duplicate certificate')
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(`Certificate for ${domain} already exists. Skipping certificate creation.`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
throw new Error(`Failed to obtain SSL certificate for ${domain}: ${errorMessage}`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Renew all SSL certificates
|
|
55
|
+
*/
|
|
56
|
+
export async function renewCertificates(): Promise<void> {
|
|
57
|
+
try {
|
|
58
|
+
await execa('sudo', ['certbot', 'renew', '--quiet'])
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Failed to renew SSL certificates: ${error instanceof Error ? error.message : error}`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a certificate exists for a domain
|
|
68
|
+
*/
|
|
69
|
+
export async function certificateExists(domain: string): Promise<boolean> {
|
|
70
|
+
try {
|
|
71
|
+
// First, check if certificate files exist using test command (most reliable)
|
|
72
|
+
try {
|
|
73
|
+
await execa('sudo', ['test', '-f', `/etc/letsencrypt/live/${domain}/fullchain.pem`])
|
|
74
|
+
await execa('sudo', ['test', '-f', `/etc/letsencrypt/live/${domain}/privkey.pem`])
|
|
75
|
+
// Both files exist
|
|
76
|
+
return true
|
|
77
|
+
} catch {
|
|
78
|
+
// Files don't exist, continue to certbot check
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fallback: Check using certbot certificates command
|
|
82
|
+
try {
|
|
83
|
+
const { stdout } = await execa('sudo', ['certbot', 'certificates'])
|
|
84
|
+
|
|
85
|
+
// Check if the domain appears in the certificates list
|
|
86
|
+
const lines = stdout.split('\n')
|
|
87
|
+
for (let i = 0; i < lines.length; i++) {
|
|
88
|
+
const line = lines[i]
|
|
89
|
+
// Check if this line contains "Domains:" and includes our domain
|
|
90
|
+
if (line.includes('Domains:') && line.includes(domain)) {
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
// Also check for the domain in certificate paths
|
|
94
|
+
if (
|
|
95
|
+
line.includes(domain) &&
|
|
96
|
+
(line.includes('/live/') || line.includes('Certificate Name:'))
|
|
97
|
+
) {
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// If certbot command fails, assume no certificate exists
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return false
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// If all checks fail, assume no certificate exists
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check certificate expiration for a domain
|
|
114
|
+
*/
|
|
115
|
+
export async function checkCertificateExpiration(domain: string): Promise<Date | null> {
|
|
116
|
+
try {
|
|
117
|
+
const { stdout } = await execa('sudo', ['certbot', 'certificates', '-d', domain])
|
|
118
|
+
|
|
119
|
+
// Parse expiration date from output
|
|
120
|
+
const expiryMatch = stdout.match(/Expiry Date: ([^\n]+)/)
|
|
121
|
+
if (expiryMatch) {
|
|
122
|
+
return new Date(expiryMatch[1])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Revoke a certificate for a domain
|
|
133
|
+
*/
|
|
134
|
+
export async function revokeCertificate(domain: string): Promise<void> {
|
|
135
|
+
try {
|
|
136
|
+
await execa('sudo', ['certbot', 'revoke', '-d', domain, '--non-interactive'])
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Failed to revoke certificate for ${domain}: ${
|
|
140
|
+
error instanceof Error ? error.message : error
|
|
141
|
+
}`
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
}
|