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,135 @@
|
|
|
1
|
+
import { config } from 'dotenv'
|
|
2
|
+
import fs from 'fs-extra'
|
|
3
|
+
import { resolve } from 'path'
|
|
4
|
+
|
|
5
|
+
// Store loaded .env variables globally so they can be accessed by other modules
|
|
6
|
+
let loadedEnvVars: Record<string, string> = {}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the currently loaded .env variables
|
|
10
|
+
*/
|
|
11
|
+
export function getLoadedEnvVars(): Record<string, string> {
|
|
12
|
+
return { ...loadedEnvVars }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Set the loaded .env variables (used internally)
|
|
17
|
+
*/
|
|
18
|
+
export function setLoadedEnvVars(vars: Record<string, string>): void {
|
|
19
|
+
loadedEnvVars = { ...vars }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load environment variables from .env files
|
|
24
|
+
* Searches for .env files in the following order:
|
|
25
|
+
* 1. .env.local (highest priority, should be gitignored)
|
|
26
|
+
* 2. .env
|
|
27
|
+
*
|
|
28
|
+
* @param configDir Optional directory path to search for .env files (defaults to current working directory)
|
|
29
|
+
* @returns Object containing loaded environment variables
|
|
30
|
+
*/
|
|
31
|
+
export async function loadEnvFiles(configDir?: string): Promise<Record<string, string>> {
|
|
32
|
+
const baseDir = configDir || process.cwd()
|
|
33
|
+
const envFiles = ['.env.local', '.env']
|
|
34
|
+
const loadedVars: Record<string, string> = {}
|
|
35
|
+
|
|
36
|
+
for (const envFile of envFiles) {
|
|
37
|
+
const envPath = resolve(baseDir, envFile)
|
|
38
|
+
|
|
39
|
+
if (await fs.pathExists(envPath)) {
|
|
40
|
+
try {
|
|
41
|
+
// Load .env file using dotenv
|
|
42
|
+
const result = config({ path: envPath, override: false })
|
|
43
|
+
|
|
44
|
+
if (result.error) {
|
|
45
|
+
console.warn(`Warning: Failed to load ${envFile}: ${result.error.message}`)
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Merge loaded variables (later files override earlier ones)
|
|
50
|
+
if (result.parsed) {
|
|
51
|
+
Object.assign(loadedVars, result.parsed)
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.warn(
|
|
55
|
+
`Warning: Error loading ${envFile}: ${error instanceof Error ? error.message : error}`
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return loadedVars
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Load and apply environment variables from .env files to process.env
|
|
66
|
+
* This will load .env files and merge them into process.env
|
|
67
|
+
*
|
|
68
|
+
* @param configDir Optional directory path to search for .env files
|
|
69
|
+
* @returns Object containing loaded environment variables
|
|
70
|
+
*/
|
|
71
|
+
export async function loadAndApplyEnvFiles(configDir?: string): Promise<Record<string, string>> {
|
|
72
|
+
const baseDir = configDir || process.cwd()
|
|
73
|
+
const envFiles = ['.env.local', '.env']
|
|
74
|
+
const loadedVars: Record<string, string> = {}
|
|
75
|
+
|
|
76
|
+
for (const envFile of envFiles) {
|
|
77
|
+
const envPath = resolve(baseDir, envFile)
|
|
78
|
+
|
|
79
|
+
if (await fs.pathExists(envPath)) {
|
|
80
|
+
try {
|
|
81
|
+
// Load .env file using dotenv and apply to process.env
|
|
82
|
+
// override: false means existing process.env vars take precedence
|
|
83
|
+
const result = config({ path: envPath, override: false })
|
|
84
|
+
|
|
85
|
+
if (result.error) {
|
|
86
|
+
console.warn(`Warning: Failed to load ${envFile}: ${result.error.message}`)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Merge loaded variables into our return object
|
|
91
|
+
if (result.parsed) {
|
|
92
|
+
Object.assign(loadedVars, result.parsed)
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.warn(
|
|
96
|
+
`Warning: Error loading ${envFile}: ${error instanceof Error ? error.message : error}`
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Store loaded vars globally for use by other modules
|
|
103
|
+
setLoadedEnvVars(loadedVars)
|
|
104
|
+
|
|
105
|
+
return loadedVars
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Merge environment variables from multiple sources
|
|
110
|
+
* Priority order (highest to lowest): CLI env vars > Service env vars > .env file vars
|
|
111
|
+
*
|
|
112
|
+
* @param envVars Environment variables from .env files
|
|
113
|
+
* @param serviceEnv Service-specific environment variables from config
|
|
114
|
+
* @param cliEnvVars Environment variables from CLI (highest priority)
|
|
115
|
+
* @returns Merged environment variables object
|
|
116
|
+
*/
|
|
117
|
+
export function mergeEnvVars(
|
|
118
|
+
envVars: Record<string, string>,
|
|
119
|
+
serviceEnv?: Record<string, string>,
|
|
120
|
+
cliEnvVars?: Record<string, string>
|
|
121
|
+
): Record<string, string> {
|
|
122
|
+
const merged = { ...envVars }
|
|
123
|
+
|
|
124
|
+
// Service environment variables override .env file variables
|
|
125
|
+
if (serviceEnv) {
|
|
126
|
+
Object.assign(merged, serviceEnv)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// CLI environment variables override both .env and service variables (highest priority)
|
|
130
|
+
if (cliEnvVars) {
|
|
131
|
+
Object.assign(merged, cliEnvVars)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return merged
|
|
135
|
+
}
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import fs from 'fs-extra'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import type { ServiceConfig } from '../types/config'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if a domain is a root domain (no subdomain)
|
|
8
|
+
* Root domain: example.com (2 parts)
|
|
9
|
+
* Subdomain: dev.example.com (3+ parts)
|
|
10
|
+
*/
|
|
11
|
+
export function isRootDomain(domain: string): boolean {
|
|
12
|
+
// Remove 'www.' prefix if present for checking
|
|
13
|
+
const domainWithoutWww = domain.startsWith('www.') ? domain.substring(4) : domain
|
|
14
|
+
const partsWithoutWww = domainWithoutWww.split('.')
|
|
15
|
+
// Root domain has exactly 2 parts (domain + TLD)
|
|
16
|
+
return partsWithoutWww.length === 2
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get canonical domain for a given domain
|
|
21
|
+
* For root domains with both www and non-www, returns www version
|
|
22
|
+
* Otherwise returns the domain itself
|
|
23
|
+
*/
|
|
24
|
+
export function getCanonicalDomain(domain: string, allDomains: Set<string>): string {
|
|
25
|
+
if (isRootDomain(domain)) {
|
|
26
|
+
const domainWithoutWww = domain.startsWith('www.') ? domain.substring(4) : domain
|
|
27
|
+
const wwwVersion = `www.${domainWithoutWww}`
|
|
28
|
+
const nonWwwVersion = domainWithoutWww
|
|
29
|
+
|
|
30
|
+
// If we have both www and non-www versions, prefer www
|
|
31
|
+
if (allDomains.has(wwwVersion) && allDomains.has(nonWwwVersion)) {
|
|
32
|
+
return wwwVersion
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return domain
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalize domain list to handle www/non-www variants
|
|
40
|
+
* For root domains: combine www and non-www in same server_name
|
|
41
|
+
* For subdomains: keep as configured
|
|
42
|
+
* Returns canonical domain (for SSL certs) and combined server names
|
|
43
|
+
*/
|
|
44
|
+
function normalizeDomains(domains: string[]): {
|
|
45
|
+
canonical: string
|
|
46
|
+
serverNames: string
|
|
47
|
+
} {
|
|
48
|
+
// Use first domain as canonical (for SSL certificates)
|
|
49
|
+
let canonical: string = domains[0]
|
|
50
|
+
|
|
51
|
+
// For root domains with both www and non-www, prefer www as canonical
|
|
52
|
+
const domainSet = new Set(domains)
|
|
53
|
+
for (const domain of domains) {
|
|
54
|
+
if (isRootDomain(domain)) {
|
|
55
|
+
const domainWithoutWww = domain.startsWith('www.') ? domain.substring(4) : domain
|
|
56
|
+
const wwwVersion = `www.${domainWithoutWww}`
|
|
57
|
+
const nonWwwVersion = domainWithoutWww
|
|
58
|
+
|
|
59
|
+
// If we have both www and non-www versions of a root domain
|
|
60
|
+
if (domainSet.has(wwwVersion) && domainSet.has(nonWwwVersion)) {
|
|
61
|
+
// Prefer www for root domains (for SSL cert path)
|
|
62
|
+
canonical = wwwVersion
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Combine all domains in server_name
|
|
69
|
+
// For root domains with both www and non-www, ensure www comes first
|
|
70
|
+
const sortedDomains = [...domains]
|
|
71
|
+
if (canonical.startsWith('www.')) {
|
|
72
|
+
const nonWww = canonical.substring(4)
|
|
73
|
+
const wwwIndex = sortedDomains.indexOf(canonical)
|
|
74
|
+
const nonWwwIndex = sortedDomains.indexOf(nonWww)
|
|
75
|
+
if (wwwIndex !== -1 && nonWwwIndex !== -1 && wwwIndex > nonWwwIndex) {
|
|
76
|
+
// Swap to put www first
|
|
77
|
+
;[sortedDomains[wwwIndex], sortedDomains[nonWwwIndex]] = [
|
|
78
|
+
sortedDomains[nonWwwIndex],
|
|
79
|
+
sortedDomains[wwwIndex],
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const serverNames = sortedDomains.join(' ')
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
canonical,
|
|
87
|
+
serverNames,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generate Nginx server block configuration for a service
|
|
93
|
+
*/
|
|
94
|
+
export function generateNginxConfig(
|
|
95
|
+
service: ServiceConfig,
|
|
96
|
+
withHttps: boolean,
|
|
97
|
+
portOverride?: number
|
|
98
|
+
): string {
|
|
99
|
+
// Normalize domains - combine www and root domains in same server_name
|
|
100
|
+
const { canonical, serverNames } = normalizeDomains(service.domains)
|
|
101
|
+
|
|
102
|
+
// Use canonical domain for upstream naming and SSL certificates
|
|
103
|
+
const domainSafe = canonical.replace(/\./g, '_').replace(/[^a-zA-Z0-9_]/g, '_')
|
|
104
|
+
const upstreamName = `${domainSafe}_${service.name}`
|
|
105
|
+
const servicePath = service.path || '/'
|
|
106
|
+
const port = portOverride || service.port
|
|
107
|
+
|
|
108
|
+
let config = `# Nginx configuration for ${service.name}\n\n`
|
|
109
|
+
|
|
110
|
+
// Upstream configuration
|
|
111
|
+
config += `upstream ${upstreamName} {\n`
|
|
112
|
+
config += ` server localhost:${port} max_fails=3 fail_timeout=30s;\n`
|
|
113
|
+
config += ` keepalive 32;\n`
|
|
114
|
+
config += `}\n\n`
|
|
115
|
+
|
|
116
|
+
if (withHttps) {
|
|
117
|
+
// HTTP server - redirect to HTTPS
|
|
118
|
+
config += `server {\n`
|
|
119
|
+
config += ` listen 80;\n`
|
|
120
|
+
config += ` listen [::]:80;\n`
|
|
121
|
+
config += ` server_name ${serverNames};\n\n`
|
|
122
|
+
config += ` # Redirect all HTTP to HTTPS\n`
|
|
123
|
+
config += ` return 301 https://$server_name$request_uri;\n`
|
|
124
|
+
config += `}\n\n`
|
|
125
|
+
|
|
126
|
+
// HTTPS server
|
|
127
|
+
config += `server {\n`
|
|
128
|
+
config += ` listen 443 ssl http2;\n`
|
|
129
|
+
config += ` listen [::]:443 ssl http2;\n`
|
|
130
|
+
config += ` server_name ${serverNames};\n\n`
|
|
131
|
+
|
|
132
|
+
// SSL configuration
|
|
133
|
+
config += ` # SSL Configuration\n`
|
|
134
|
+
config += ` ssl_certificate /etc/letsencrypt/live/${canonical}/fullchain.pem;\n`
|
|
135
|
+
config += ` ssl_certificate_key /etc/letsencrypt/live/${canonical}/privkey.pem;\n`
|
|
136
|
+
config += ` ssl_protocols TLSv1.2 TLSv1.3;\n`
|
|
137
|
+
config += ` ssl_ciphers HIGH:!aNULL:!MD5;\n`
|
|
138
|
+
config += ` ssl_prefer_server_ciphers on;\n\n`
|
|
139
|
+
} else {
|
|
140
|
+
// HTTP only server
|
|
141
|
+
config += `server {\n`
|
|
142
|
+
config += ` listen 80;\n`
|
|
143
|
+
config += ` listen [::]:80;\n`
|
|
144
|
+
config += ` server_name ${serverNames};\n\n`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Logging
|
|
148
|
+
config += ` # Logging\n`
|
|
149
|
+
config += ` access_log /var/log/nginx/${service.name}_access.log;\n`
|
|
150
|
+
config += ` error_log /var/log/nginx/${service.name}_error.log;\n\n`
|
|
151
|
+
|
|
152
|
+
// Client settings
|
|
153
|
+
config += ` # Client settings\n`
|
|
154
|
+
config += ` client_max_body_size 100M;\n\n`
|
|
155
|
+
|
|
156
|
+
// Proxy settings
|
|
157
|
+
config += ` # Proxy settings\n`
|
|
158
|
+
config += ` location ${servicePath} {\n`
|
|
159
|
+
config += ` proxy_pass http://${upstreamName};\n`
|
|
160
|
+
config += ` proxy_http_version 1.1;\n`
|
|
161
|
+
config += ` proxy_set_header Upgrade $http_upgrade;\n`
|
|
162
|
+
config += ` proxy_set_header Connection 'upgrade';\n`
|
|
163
|
+
config += ` proxy_set_header Host $host;\n`
|
|
164
|
+
config += ` proxy_set_header X-Real-IP $remote_addr;\n`
|
|
165
|
+
config += ` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n`
|
|
166
|
+
config += ` proxy_set_header X-Forwarded-Proto $scheme;\n`
|
|
167
|
+
config += ` proxy_cache_bypass $http_upgrade;\n`
|
|
168
|
+
config += ` proxy_connect_timeout 60s;\n`
|
|
169
|
+
config += ` proxy_send_timeout 60s;\n`
|
|
170
|
+
config += ` proxy_read_timeout 60s;\n`
|
|
171
|
+
config += ` }\n`
|
|
172
|
+
|
|
173
|
+
// Health check endpoint (if configured)
|
|
174
|
+
if (service.healthCheck) {
|
|
175
|
+
config += `\n # Health check endpoint\n`
|
|
176
|
+
config += ` location ${service.healthCheck.path} {\n`
|
|
177
|
+
config += ` proxy_pass http://${upstreamName};\n`
|
|
178
|
+
config += ` access_log off;\n`
|
|
179
|
+
config += ` }\n`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
config += `}\n`
|
|
183
|
+
|
|
184
|
+
return config
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Generate Nginx configuration for multiple services on the same domain
|
|
189
|
+
* Groups services by domain and creates location blocks for each service path
|
|
190
|
+
* Combines upstreams when multiple services share the same port
|
|
191
|
+
*/
|
|
192
|
+
export function generateMultiServiceNginxConfig(
|
|
193
|
+
services: ServiceConfig[],
|
|
194
|
+
domain: string,
|
|
195
|
+
withHttps: boolean,
|
|
196
|
+
portOverrides?: Map<string, number>
|
|
197
|
+
): string {
|
|
198
|
+
const upstreams: string[] = []
|
|
199
|
+
const locations: string[] = []
|
|
200
|
+
const healthChecks: string[] = []
|
|
201
|
+
|
|
202
|
+
// Collect all domains from services to check for www/non-www variants
|
|
203
|
+
const allServiceDomains = new Set<string>()
|
|
204
|
+
for (const service of services) {
|
|
205
|
+
for (const d of service.domains) {
|
|
206
|
+
allServiceDomains.add(d)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Determine canonical domain (for SSL certs) and combined server names
|
|
211
|
+
// For root domains: combine www and non-www in same server_name
|
|
212
|
+
// For subdomains: use as-is
|
|
213
|
+
let canonicalDomain = domain
|
|
214
|
+
let serverNames = domain
|
|
215
|
+
|
|
216
|
+
if (isRootDomain(domain)) {
|
|
217
|
+
const domainWithoutWww = domain.startsWith('www.') ? domain.substring(4) : domain
|
|
218
|
+
const wwwVersion = `www.${domainWithoutWww}`
|
|
219
|
+
const nonWwwVersion = domainWithoutWww
|
|
220
|
+
|
|
221
|
+
if (allServiceDomains.has(wwwVersion) && allServiceDomains.has(nonWwwVersion)) {
|
|
222
|
+
// Prefer www for root domains (for SSL cert path)
|
|
223
|
+
canonicalDomain = wwwVersion
|
|
224
|
+
// Combine both in server_name (www first)
|
|
225
|
+
serverNames = `${wwwVersion} ${nonWwwVersion}`
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Subdomains are left as-is
|
|
229
|
+
|
|
230
|
+
// Sort services by path length (longest first) to ensure specific paths are matched before general ones
|
|
231
|
+
const sortedServices = [...services].sort((a, b) => {
|
|
232
|
+
const pathA = (a.path || '/').length
|
|
233
|
+
const pathB = (b.path || '/').length
|
|
234
|
+
return pathB - pathA
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// Map port to upstream name - combine services with same port into one upstream
|
|
238
|
+
const portToUpstreamName = new Map<number, string>()
|
|
239
|
+
const domainSafe = canonicalDomain.replace(/\./g, '_').replace(/[^a-zA-Z0-9_]/g, '_')
|
|
240
|
+
|
|
241
|
+
// First pass: create upstreams grouped by port
|
|
242
|
+
for (const service of sortedServices) {
|
|
243
|
+
const port = portOverrides?.get(service.name) || service.port
|
|
244
|
+
|
|
245
|
+
// If we haven't seen this port before, create a new upstream
|
|
246
|
+
if (!portToUpstreamName.has(port)) {
|
|
247
|
+
// Use domain_port format for upstream name to ensure uniqueness
|
|
248
|
+
// Since ports are unique within a domain, this format ensures no conflicts
|
|
249
|
+
const upstreamName = `${domainSafe}_port_${port}`
|
|
250
|
+
|
|
251
|
+
portToUpstreamName.set(port, upstreamName)
|
|
252
|
+
|
|
253
|
+
// Generate upstream block
|
|
254
|
+
upstreams.push(`upstream ${upstreamName} {`)
|
|
255
|
+
upstreams.push(` server localhost:${port} max_fails=3 fail_timeout=30s;`)
|
|
256
|
+
upstreams.push(` keepalive 32;`)
|
|
257
|
+
upstreams.push(`}`)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Second pass: create location blocks for each service, using the shared upstream
|
|
262
|
+
for (const service of sortedServices) {
|
|
263
|
+
const servicePath = service.path || '/'
|
|
264
|
+
const port = portOverrides?.get(service.name) || service.port
|
|
265
|
+
const upstreamName = portToUpstreamName.get(port)!
|
|
266
|
+
|
|
267
|
+
// Generate location block
|
|
268
|
+
if (servicePath === '/') {
|
|
269
|
+
// Root path - use exact match or default
|
|
270
|
+
locations.push(` # Service: ${service.name}`)
|
|
271
|
+
locations.push(` location / {`)
|
|
272
|
+
} else {
|
|
273
|
+
// Specific path - use prefix match
|
|
274
|
+
locations.push(` # Service: ${service.name}`)
|
|
275
|
+
locations.push(` location ${servicePath} {`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
locations.push(` proxy_pass http://${upstreamName};`)
|
|
279
|
+
locations.push(` proxy_http_version 1.1;`)
|
|
280
|
+
locations.push(` proxy_set_header Upgrade $http_upgrade;`)
|
|
281
|
+
locations.push(` proxy_set_header Connection 'upgrade';`)
|
|
282
|
+
locations.push(` proxy_set_header Host $host;`)
|
|
283
|
+
locations.push(` proxy_set_header X-Real-IP $remote_addr;`)
|
|
284
|
+
locations.push(` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`)
|
|
285
|
+
locations.push(` proxy_set_header X-Forwarded-Proto $scheme;`)
|
|
286
|
+
locations.push(` proxy_cache_bypass $http_upgrade;`)
|
|
287
|
+
locations.push(` proxy_connect_timeout 60s;`)
|
|
288
|
+
locations.push(` proxy_send_timeout 60s;`)
|
|
289
|
+
locations.push(` proxy_read_timeout 60s;`)
|
|
290
|
+
locations.push(` }`)
|
|
291
|
+
|
|
292
|
+
// Health check endpoint
|
|
293
|
+
if (service.healthCheck) {
|
|
294
|
+
healthChecks.push(` # Health check for ${service.name}`)
|
|
295
|
+
healthChecks.push(` location ${service.healthCheck.path} {`)
|
|
296
|
+
healthChecks.push(` proxy_pass http://${upstreamName};`)
|
|
297
|
+
healthChecks.push(` access_log off;`)
|
|
298
|
+
healthChecks.push(` }`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let config = `# Nginx configuration for ${canonicalDomain}\n`
|
|
303
|
+
config += `# Multiple services on the same domain\n\n`
|
|
304
|
+
|
|
305
|
+
// Add upstreams
|
|
306
|
+
config += upstreams.join('\n') + '\n\n'
|
|
307
|
+
|
|
308
|
+
if (withHttps) {
|
|
309
|
+
// HTTP server - redirect to HTTPS
|
|
310
|
+
config += `server {\n`
|
|
311
|
+
config += ` listen 80;\n`
|
|
312
|
+
config += ` listen [::]:80;\n`
|
|
313
|
+
config += ` server_name ${serverNames};\n\n`
|
|
314
|
+
config += ` # Redirect all HTTP to HTTPS\n`
|
|
315
|
+
config += ` return 301 https://$server_name$request_uri;\n`
|
|
316
|
+
config += `}\n\n`
|
|
317
|
+
|
|
318
|
+
// HTTPS server
|
|
319
|
+
config += `server {\n`
|
|
320
|
+
config += ` listen 443 ssl http2;\n`
|
|
321
|
+
config += ` listen [::]:443 ssl http2;\n`
|
|
322
|
+
config += ` server_name ${serverNames};\n\n`
|
|
323
|
+
|
|
324
|
+
// SSL configuration
|
|
325
|
+
config += ` # SSL Configuration\n`
|
|
326
|
+
config += ` ssl_certificate /etc/letsencrypt/live/${canonicalDomain}/fullchain.pem;\n`
|
|
327
|
+
config += ` ssl_certificate_key /etc/letsencrypt/live/${canonicalDomain}/privkey.pem;\n`
|
|
328
|
+
config += ` ssl_protocols TLSv1.2 TLSv1.3;\n`
|
|
329
|
+
config += ` ssl_ciphers HIGH:!aNULL:!MD5;\n`
|
|
330
|
+
config += ` ssl_prefer_server_ciphers on;\n\n`
|
|
331
|
+
} else {
|
|
332
|
+
// HTTP only server
|
|
333
|
+
config += `server {\n`
|
|
334
|
+
config += ` listen 80;\n`
|
|
335
|
+
config += ` listen [::]:80;\n`
|
|
336
|
+
config += ` server_name ${serverNames};\n\n`
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Logging
|
|
340
|
+
config += ` # Logging\n`
|
|
341
|
+
config += ` access_log /var/log/nginx/${canonicalDomain}_access.log;\n`
|
|
342
|
+
config += ` error_log /var/log/nginx/${canonicalDomain}_error.log;\n\n`
|
|
343
|
+
|
|
344
|
+
// Client settings
|
|
345
|
+
config += ` # Client settings\n`
|
|
346
|
+
config += ` client_max_body_size 100M;\n\n`
|
|
347
|
+
|
|
348
|
+
// Location blocks
|
|
349
|
+
config += ` # Service locations\n`
|
|
350
|
+
config += locations.join('\n') + '\n\n'
|
|
351
|
+
|
|
352
|
+
// Health check endpoints
|
|
353
|
+
if (healthChecks.length > 0) {
|
|
354
|
+
config += ` # Health check endpoints\n`
|
|
355
|
+
config += healthChecks.join('\n') + '\n'
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
config += `}\n`
|
|
359
|
+
|
|
360
|
+
return config
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Check if an Nginx config file exists
|
|
365
|
+
*/
|
|
366
|
+
export async function configExists(configName: string, configPath: string): Promise<boolean> {
|
|
367
|
+
const configFilePath = path.join(configPath, `${configName}.conf`)
|
|
368
|
+
return await fs.pathExists(configFilePath)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Write Nginx configuration file, deleting existing file if it exists and creating new one
|
|
373
|
+
*/
|
|
374
|
+
export async function writeNginxConfig(
|
|
375
|
+
configName: string,
|
|
376
|
+
configPath: string,
|
|
377
|
+
configContent: string
|
|
378
|
+
): Promise<boolean> {
|
|
379
|
+
const configFilePath = path.join(configPath, `${configName}.conf`)
|
|
380
|
+
const exists = await fs.pathExists(configFilePath)
|
|
381
|
+
|
|
382
|
+
// If config file exists, delete it first
|
|
383
|
+
if (exists) {
|
|
384
|
+
await fs.remove(configFilePath)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Create new config file
|
|
388
|
+
await fs.writeFile(configFilePath, configContent)
|
|
389
|
+
|
|
390
|
+
return exists // Return true if file existed (was deleted and recreated)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Enable an Nginx site by creating a symbolic link
|
|
395
|
+
*/
|
|
396
|
+
export async function enableSite(siteName: string, configPath: string): Promise<void> {
|
|
397
|
+
const availablePath = path.join(configPath, `${siteName}.conf`)
|
|
398
|
+
const enabledPath = availablePath.replace('sites-available', 'sites-enabled')
|
|
399
|
+
|
|
400
|
+
// Create sites-enabled directory if it doesn't exist
|
|
401
|
+
await fs.ensureDir(path.dirname(enabledPath))
|
|
402
|
+
|
|
403
|
+
// Remove existing symlink if present
|
|
404
|
+
if (await fs.pathExists(enabledPath)) {
|
|
405
|
+
await fs.remove(enabledPath)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Create symlink
|
|
409
|
+
await execa('sudo', ['ln', '-sf', availablePath, enabledPath])
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Test and reload Nginx configuration
|
|
414
|
+
*/
|
|
415
|
+
export async function reloadNginx(reloadCommand: string): Promise<void> {
|
|
416
|
+
try {
|
|
417
|
+
// Test configuration first
|
|
418
|
+
await execa('sudo', ['nginx', '-t'])
|
|
419
|
+
|
|
420
|
+
// Reload Nginx
|
|
421
|
+
const parts = reloadCommand.split(' ')
|
|
422
|
+
if (parts.length > 0) {
|
|
423
|
+
// Simple execution of provided command
|
|
424
|
+
await execa(parts[0], parts.slice(1), { shell: true })
|
|
425
|
+
}
|
|
426
|
+
} catch (error) {
|
|
427
|
+
throw new Error(`Failed to reload Nginx: ${error instanceof Error ? error.message : error}`)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Disable an Nginx site
|
|
433
|
+
*/
|
|
434
|
+
export async function disableSite(siteName: string, configPath: string): Promise<void> {
|
|
435
|
+
const enabledPath = path.join(
|
|
436
|
+
configPath.replace('sites-available', 'sites-enabled'),
|
|
437
|
+
`${siteName}.conf`
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if (await fs.pathExists(enabledPath)) {
|
|
441
|
+
await fs.remove(enabledPath)
|
|
442
|
+
}
|
|
443
|
+
}
|
package/suthep-1.0.0.tgz
ADDED
|
Binary file
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
project:
|
|
2
|
+
name: my-app
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
|
|
5
|
+
services:
|
|
6
|
+
# Example 1: Simple Node.js service
|
|
7
|
+
- name: api
|
|
8
|
+
port: 3000
|
|
9
|
+
domains:
|
|
10
|
+
- api.example.com
|
|
11
|
+
- www.api.example.com
|
|
12
|
+
healthCheck:
|
|
13
|
+
path: /health
|
|
14
|
+
interval: 30
|
|
15
|
+
environment:
|
|
16
|
+
NODE_ENV: production
|
|
17
|
+
PORT: 3000
|
|
18
|
+
|
|
19
|
+
# Example 2: Docker container service
|
|
20
|
+
- name: webapp
|
|
21
|
+
port: 8080
|
|
22
|
+
docker:
|
|
23
|
+
image: nginx:latest
|
|
24
|
+
container: webapp-container
|
|
25
|
+
port: 80
|
|
26
|
+
domains:
|
|
27
|
+
- example.com
|
|
28
|
+
- www.example.com
|
|
29
|
+
healthCheck:
|
|
30
|
+
path: /
|
|
31
|
+
interval: 30
|
|
32
|
+
|
|
33
|
+
# Example 3: Multiple subdomains with Docker
|
|
34
|
+
- name: dashboard
|
|
35
|
+
port: 5000
|
|
36
|
+
docker:
|
|
37
|
+
image: myapp/dashboard:latest
|
|
38
|
+
container: dashboard-container
|
|
39
|
+
port: 5000
|
|
40
|
+
domains:
|
|
41
|
+
- dashboard.example.com
|
|
42
|
+
- admin.example.com
|
|
43
|
+
healthCheck:
|
|
44
|
+
path: /api/health
|
|
45
|
+
interval: 60
|
|
46
|
+
environment:
|
|
47
|
+
DATABASE_URL: postgresql://localhost:5432/dashboard
|
|
48
|
+
REDIS_URL: redis://localhost:6379
|
|
49
|
+
|
|
50
|
+
# Example 4: Service connecting to existing Docker container
|
|
51
|
+
- name: database-proxy
|
|
52
|
+
port: 5432
|
|
53
|
+
docker:
|
|
54
|
+
container: postgres-container
|
|
55
|
+
port: 5432
|
|
56
|
+
domains:
|
|
57
|
+
- db.example.com
|
|
58
|
+
|
|
59
|
+
# Example 5: Multiple services on the same domain with path-based routing
|
|
60
|
+
# API service on /api path
|
|
61
|
+
- name: api
|
|
62
|
+
port: 3001
|
|
63
|
+
path: /api
|
|
64
|
+
domains:
|
|
65
|
+
- muacle.com
|
|
66
|
+
docker:
|
|
67
|
+
image: myapp/api:latest
|
|
68
|
+
container: api-container
|
|
69
|
+
port: 3001
|
|
70
|
+
healthCheck:
|
|
71
|
+
path: /health
|
|
72
|
+
interval: 30
|
|
73
|
+
|
|
74
|
+
# UI service on root path
|
|
75
|
+
- name: ui
|
|
76
|
+
port: 3000
|
|
77
|
+
path: /
|
|
78
|
+
domains:
|
|
79
|
+
- muacle.com
|
|
80
|
+
docker:
|
|
81
|
+
image: myapp/ui:latest
|
|
82
|
+
container: ui-container
|
|
83
|
+
port: 3000
|
|
84
|
+
healthCheck:
|
|
85
|
+
path: /
|
|
86
|
+
interval: 30
|
|
87
|
+
|
|
88
|
+
nginx:
|
|
89
|
+
configPath: /etc/nginx/sites-available
|
|
90
|
+
reloadCommand: sudo nginx -t && sudo systemctl reload nginx
|
|
91
|
+
|
|
92
|
+
certbot:
|
|
93
|
+
email: admin@example.com
|
|
94
|
+
staging: false # Set to true for testing
|
|
95
|
+
|
|
96
|
+
deployment:
|
|
97
|
+
strategy: rolling # Options: rolling, blue-green
|
|
98
|
+
healthCheckTimeout: 30000 # milliseconds
|